Skip to main content

deps_lsp/handlers/
code_lens.rs

1//! Code lens handler: "Update N outdated dependencies".
2//!
3//! Mirrors `handlers::code_actions`' delegation pattern, but produces a single
4//! document-scoped lens (or none) bound to [`COMMAND_ID`] rather than a per-position
5//! action. The command handler that applies the edit lives in `server::execute_command`,
6//! not here — it needs the raw `Vec<TextEdit>`, not the wrapped `CodeLens`.
7
8use crate::config::DepsConfig;
9use crate::document::{ServerState, ensure_document_loaded};
10use deps_core::VersionData;
11use std::sync::Arc;
12use tokio::sync::RwLock;
13use tower_lsp_server::Client;
14use tower_lsp_server::ls_types::{CodeLens, CodeLensParams};
15
16/// The `workspace/executeCommand` id bound to the lens produced here.
17pub const COMMAND_ID: &str = "deps-lsp.updateAllOutdated";
18
19/// Handles `textDocument/codeLens` requests using trait-based delegation.
20///
21/// Returns zero or one lens for the document. Zero when: the code lens feature is
22/// disabled (`enabled` is `false`); the document cannot be loaded; it has no parse
23/// result; it is not [ready for a batch
24/// update](crate::document::DocumentState::is_ready_for_batch_update) (version data is
25/// still loading, or the document has no known LSP version — the same two conditions
26/// `execute_update_all_outdated` requires, so the lens never renders a click target the
27/// command would then refuse); or every dependency is up to date / not safely editable
28/// (see `deps_core::lsp_helpers::collect_update_all_edits`).
29pub async fn handle_code_lens(
30    state: Arc<ServerState>,
31    params: CodeLensParams,
32    enabled: bool,
33    client: Client,
34    config: Arc<RwLock<DepsConfig>>,
35) -> Vec<CodeLens> {
36    if !enabled {
37        return vec![];
38    }
39
40    let uri = &params.text_document.uri;
41
42    // Ensure document is loaded (cold start support)
43    if !ensure_document_loaded(uri, Arc::clone(&state), client, Arc::clone(&config)).await {
44        tracing::warn!("Could not load document for code lens: {:?}", uri);
45        return vec![];
46    }
47
48    let offline = { config.read().await.network.offline };
49
50    // Own everything `generate_code_lenses` needs and release the DashMap shard `Ref`
51    // before awaiting it (#333): `with_document` only ever hands `extract` a borrowed
52    // `&DocumentState` synchronously, so the guard can't leak across the `.await` below.
53    let Some((ecosystem, parse_result, content, cached_versions, resolved_versions)) = state
54        .with_document(uri, |doc| {
55            let ecosystem = state.ecosystem_registry.get(doc.ecosystem_id())?;
56
57            // Refuse the same conditions `execute_update_all_outdated` requires before
58            // acting, so the lens never renders a click target the command would then
59            // refuse: version data isn't `Loading` (avoids counting against an empty
60            // cache, also mirrors `diagnostics::generate_diagnostics_internal`), and the
61            // document has a known LSP version (`None` means it was loaded from disk
62            // after a missed `didOpen` — see `DocumentState::is_ready_for_batch_update`).
63            if !doc.is_ready_for_batch_update() {
64                return None;
65            }
66
67            let parse_result = doc.parse_result_arc()?;
68            Some((
69                ecosystem,
70                parse_result,
71                doc.content.clone(),
72                doc.cached_versions.clone(),
73                doc.resolved_versions.clone(),
74            ))
75        })
76        .flatten()
77    else {
78        return vec![];
79    };
80
81    ecosystem
82        .generate_code_lenses(
83            parse_result.as_ref(),
84            &content,
85            VersionData::new(&cached_versions, &resolved_versions).with_offline(offline),
86            uri,
87            COMMAND_ID,
88        )
89        .await
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::document::ServerState;
96    use crate::test_utils::test_helpers::create_test_client_and_config;
97    use deps_core::{EcosystemId, PackageVersions};
98    use tower_lsp_server::ls_types::TextDocumentIdentifier;
99
100    fn params(uri: tower_lsp_server::ls_types::Uri) -> CodeLensParams {
101        CodeLensParams {
102            text_document: TextDocumentIdentifier { uri },
103            work_done_progress_params: Default::default(),
104            partial_result_params: Default::default(),
105        }
106    }
107
108    #[tokio::test]
109    async fn test_handle_code_lens_disabled_returns_empty() {
110        let state = Arc::new(ServerState::new());
111        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
112        let (client, config) = create_test_client_and_config();
113
114        let result = handle_code_lens(state, params(uri), false, client, config).await;
115        assert!(result.is_empty());
116    }
117
118    #[tokio::test]
119    async fn test_handle_code_lens_missing_document_returns_empty() {
120        let state = Arc::new(ServerState::new());
121        let uri = deps_core::test_util::test_uri("/test/unknown.txt");
122        let (client, config) = create_test_client_and_config();
123
124        let result = handle_code_lens(state, params(uri), true, client, config).await;
125        assert!(result.is_empty());
126    }
127
128    /// #333 liveness regression: `handle_code_lens` must release the DashMap shard
129    /// `Ref` on the document *before* awaiting `Ecosystem::generate_code_lenses`, so a
130    /// concurrent `documents.get_mut` on the same URI (e.g. a `didChange`) is never
131    /// blocked behind an in-flight (or stuck) lens generation.
132    ///
133    /// `BlockingEcosystem::generate_code_lenses` waits on a `Barrier` before blocking
134    /// forever (`std::future::pending`), standing in for an override that performs real
135    /// I/O — the worst case for a shard `Ref` held across the call. The test only
136    /// proceeds to race the writer once that future has demonstrably started executing
137    /// (via the barrier); a concurrent write racing here must complete almost
138    /// immediately, proving the `Ref` was already dropped before the call was awaited.
139    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
140    async fn test_concurrent_document_write_not_blocked_by_in_flight_code_lens() {
141        use crate::document::DocumentState;
142        use crate::test_utils::blocking_ecosystem::{
143            BlockingEcosystem, BlockingHook, MockParseResult,
144        };
145        use deps_core::ParseResult;
146        use tokio::sync::Barrier;
147
148        let state = Arc::new(ServerState::new());
149        let started = Arc::new(Barrier::new(2));
150        state
151            .ecosystem_registry
152            .register(Arc::new(BlockingEcosystem {
153                started: Arc::clone(&started),
154                hook: BlockingHook::CodeLenses,
155            }));
156
157        let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
158        let content = "[dependencies]\nserde = \"1.0\"\n".to_string();
159        let parse_result: Box<dyn ParseResult> = Box::new(MockParseResult { uri: uri.clone() });
160        let mut doc =
161            DocumentState::new_from_parse_result(EcosystemId::Cargo, content, parse_result);
162        doc.set_version(Some(1));
163        state.update_document(uri.clone(), doc);
164
165        let (client, config) = create_test_client_and_config();
166
167        let handler_task = tokio::spawn({
168            let state = Arc::clone(&state);
169            let uri = uri.clone();
170            async move { handle_code_lens(state, params(uri), true, client, config).await }
171        });
172
173        // Block until `generate_code_lenses` has actually started executing — i.e.
174        // `handle_code_lens` has reached (and is now inside) the await — before racing
175        // the writer below. Timeout-wrapped so a regression that makes the handler
176        // never reach the awaited call fails loudly instead of hanging forever.
177        tokio::time::timeout(std::time::Duration::from_secs(5), started.wait())
178            .await
179            .expect("handle_code_lens did not reach generate_code_lenses within 5s");
180
181        // Spawned onto its own task (rather than awaited inline) deliberately: see
182        // `completion.rs`'s equivalent #319 regression test for why `DashMap::get_mut`
183        // needs a real async yield point to race against `tokio::time::timeout`.
184        let write_task = tokio::spawn({
185            let state = Arc::clone(&state);
186            let uri = uri.clone();
187            async move {
188                state.documents.get_mut(&uri).unwrap().set_loading();
189            }
190        });
191        let write_result =
192            tokio::time::timeout(std::time::Duration::from_millis(500), write_task).await;
193
194        handler_task.abort();
195
196        assert!(
197            write_result.is_ok(),
198            "#333 regression: a concurrent documents.get_mut on the same URI must not \
199             block on an in-flight generate_code_lenses call — the DashMap shard Ref \
200             must be dropped before the call is awaited, not after it"
201        );
202    }
203
204    #[cfg(feature = "cargo")]
205    mod cargo_tests {
206        use super::*;
207        use crate::document::DocumentState;
208
209        async fn seed(
210            state: &Arc<ServerState>,
211            uri: &tower_lsp_server::ls_types::Uri,
212            content: &str,
213            cached: std::collections::HashMap<deps_core::PackageName, deps_core::PackageVersions>,
214        ) {
215            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
216            let parse_result = ecosystem
217                .parse_manifest(content, uri)
218                .await
219                .expect("failed to parse manifest");
220            let mut doc_state = DocumentState::new_from_parse_result(
221                EcosystemId::Cargo,
222                content.to_string(),
223                parse_result,
224            );
225            doc_state.update_cached_versions(cached);
226            doc_state.set_loaded();
227            doc_state.set_version(Some(1));
228            state.update_document(uri.clone(), doc_state);
229        }
230
231        #[tokio::test]
232        async fn test_handle_code_lens_no_parse_result_returns_empty() {
233            let state = Arc::new(ServerState::new());
234            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
235            let doc_state =
236                DocumentState::new_without_parse_result(EcosystemId::Cargo, String::new());
237            state.update_document(uri.clone(), doc_state);
238
239            let (client, config) = create_test_client_and_config();
240            let result = handle_code_lens(state, params(uri), true, client, config).await;
241            assert!(result.is_empty());
242        }
243
244        #[tokio::test]
245        async fn test_handle_code_lens_loading_returns_empty() {
246            let state = Arc::new(ServerState::new());
247            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
248            let content = "[dependencies]\nserde = \"1.0.0\"\n";
249            let mut cached = std::collections::HashMap::new();
250            cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
251            seed(&state, &uri, content, cached).await;
252            state.documents.get_mut(&uri).unwrap().set_loading();
253
254            let (client, config) = create_test_client_and_config();
255            let result = handle_code_lens(state, params(uri), true, client, config).await;
256            assert!(result.is_empty());
257        }
258
259        #[tokio::test]
260        async fn test_handle_code_lens_no_version_returns_empty() {
261            // Regression guard (S1): a document with `version: None` (populated from
262            // disk after a missed didOpen) must not render a lens that
263            // `execute_update_all_outdated` would then always refuse on click.
264            let state = Arc::new(ServerState::new());
265            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
266            let content = "[dependencies]\nserde = \"1.0.0\"\n";
267            let mut cached = std::collections::HashMap::new();
268            cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
269            seed(&state, &uri, content, cached).await;
270            state.documents.get_mut(&uri).unwrap().set_version(None);
271
272            let (client, config) = create_test_client_and_config();
273            let result = handle_code_lens(state, params(uri), true, client, config).await;
274            assert!(result.is_empty());
275        }
276
277        #[tokio::test]
278        async fn test_handle_code_lens_up_to_date_fixture_returns_no_lens() {
279            let state = Arc::new(ServerState::new());
280            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
281            let content = "[dependencies]\nserde = \"1.0.0\"\n";
282            let mut cached = std::collections::HashMap::new();
283            cached.insert("serde".into(), PackageVersions::latest_only("1.0.0"));
284            seed(&state, &uri, content, cached).await;
285
286            let (client, config) = create_test_client_and_config();
287            let result = handle_code_lens(state, params(uri), true, client, config).await;
288            assert!(result.is_empty());
289        }
290
291        #[tokio::test]
292        async fn test_handle_code_lens_outdated_fixture_returns_one_lens() {
293            let state = Arc::new(ServerState::new());
294            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
295            let content = "[dependencies]\nserde = \"1.0.0\"\n";
296            let mut cached = std::collections::HashMap::new();
297            cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
298            seed(&state, &uri, content, cached).await;
299
300            let (client, config) = create_test_client_and_config();
301            let result = handle_code_lens(state, params(uri.clone()), true, client, config).await;
302
303            assert_eq!(result.len(), 1);
304            let command = result[0].command.as_ref().expect("lens has a command");
305            assert_eq!(command.title, "Update 1 outdated dependency");
306            assert_eq!(command.command, COMMAND_ID);
307            let args = command
308                .arguments
309                .as_ref()
310                .expect("command has arguments")
311                .first()
312                .expect("command has one argument");
313            assert_eq!(args["uri"], uri.as_str());
314        }
315    }
316
317    /// Cross-ecosystem consistency matrix for `collect_update_all_edits` (required by
318    /// `.claude/rules/continuous-improvement.md`'s cross-ecosystem rule and §6 of the
319    /// design plan). Exercises the real per-ecosystem parser/formatter, including the
320    /// fragile ecosystems the §4.4 literal-span guard must skip: every "passes" fixture
321    /// asserts the *resulting document text is a valid, re-parseable declaration* (not
322    /// merely "an edit exists"); every "skipped" fixture asserts no edit is produced at
323    /// all, proving the guard — not the absence of a fixture — is what stops it.
324    mod cross_ecosystem_tests {
325        use super::*;
326        use std::collections::HashMap;
327        use tower_lsp_server::ls_types::TextEdit;
328
329        /// Applies a single `TextEdit` to `content`, using the exact inverse of the
330        /// `Position`-to-byte-offset conversion `collect_update_all_edits` used to build
331        /// the edit's range in the first place.
332        fn apply_single_edit(content: &str, edit: &TextEdit) -> String {
333            let table = deps_core::LineOffsetTable::new(content);
334            let start = table.position_to_byte_offset(content, edit.range.start);
335            let end = table.position_to_byte_offset(content, edit.range.end);
336            format!("{}{}{}", &content[..start], edit.new_text, &content[end..])
337        }
338
339        /// Asserts the ecosystem produces exactly one edit for `content`, and that
340        /// applying it yields text which both contains `expected_fragment` and still
341        /// parses successfully under the same ecosystem parser.
342        async fn assert_single_edit_produces_valid_declaration(
343            ecosystem: &dyn deps_core::Ecosystem,
344            uri: &tower_lsp_server::ls_types::Uri,
345            content: &str,
346            cached: HashMap<deps_core::PackageName, deps_core::PackageVersions>,
347            expected_fragment: &str,
348        ) {
349            let parse_result = ecosystem
350                .parse_manifest(content, uri)
351                .await
352                .expect("fixture must parse");
353            let resolved = HashMap::new();
354            let edits = deps_core::collect_update_all_edits(
355                parse_result.as_ref(),
356                content,
357                deps_core::VersionData::new(&cached, &resolved),
358                ecosystem.formatter(),
359            );
360            assert_eq!(edits.len(), 1, "expected exactly one edit for this fixture");
361
362            let new_content = apply_single_edit(content, &edits[0]);
363            assert!(
364                new_content.contains(expected_fragment),
365                "resulting text should contain {expected_fragment:?}, got: {new_content}"
366            );
367            ecosystem
368                .parse_manifest(&new_content, uri)
369                .await
370                .unwrap_or_else(|e| panic!("resulting text failed to re-parse: {e}"));
371        }
372
373        /// Asserts the ecosystem produces no edit at all — the literal-span guard case.
374        async fn assert_guard_skips(
375            ecosystem: &dyn deps_core::Ecosystem,
376            uri: &tower_lsp_server::ls_types::Uri,
377            content: &str,
378            cached: HashMap<deps_core::PackageName, deps_core::PackageVersions>,
379        ) {
380            let parse_result = ecosystem
381                .parse_manifest(content, uri)
382                .await
383                .expect("fixture must parse");
384            let resolved = HashMap::new();
385            let edits = deps_core::collect_update_all_edits(
386                parse_result.as_ref(),
387                content,
388                deps_core::VersionData::new(&cached, &resolved),
389                ecosystem.formatter(),
390            );
391            assert!(
392                edits.is_empty(),
393                "expected the literal-span guard to skip this dependency"
394            );
395        }
396
397        #[cfg(feature = "cargo")]
398        #[tokio::test]
399        async fn test_cargo_literal_version_is_edited() {
400            let state = ServerState::new();
401            let ecosystem = state.ecosystem_registry.get("cargo").unwrap();
402            let uri = deps_core::test_util::test_uri("/test/Cargo.toml");
403            let content = "[dependencies]\nserde = \"1.0.0\"\n";
404            let mut cached = HashMap::new();
405            cached.insert("serde".into(), PackageVersions::latest_only("1.2.0"));
406
407            assert_single_edit_produces_valid_declaration(
408                ecosystem.as_ref(),
409                &uri,
410                content,
411                cached,
412                "serde = \"1.2.0\"",
413            )
414            .await;
415        }
416
417        #[cfg(feature = "npm")]
418        #[tokio::test]
419        async fn test_npm_literal_version_is_edited() {
420            let state = ServerState::new();
421            let ecosystem = state.ecosystem_registry.get("npm").unwrap();
422            let uri = deps_core::test_util::test_uri("/test/package.json");
423            let content = r#"{"dependencies": {"express": "^4.0.0"}}"#;
424            let mut cached = HashMap::new();
425            cached.insert("express".into(), PackageVersions::latest_only("5.0.0"));
426
427            assert_single_edit_produces_valid_declaration(
428                ecosystem.as_ref(),
429                &uri,
430                content,
431                cached,
432                "\"express\": \"5.0.0\"",
433            )
434            .await;
435        }
436
437        #[cfg(feature = "pypi")]
438        #[tokio::test]
439        async fn test_pypi_literal_version_is_edited() {
440            let state = ServerState::new();
441            let ecosystem = state.ecosystem_registry.get("pypi").unwrap();
442            let uri = deps_core::test_util::test_uri("/test/pyproject.toml");
443            // An exact pin, not a lower bound: "==2.0.0" does not accept "2.5.0", unlike
444            // ">=2.0.0" (which the "already accepts latest" rule would correctly skip).
445            // `format_version_replacing` preserves the `==` pin style rather than
446            // widening to a range (§6.1) — the edit is "requests==2.5.0", not a range.
447            let content = "[project]\ndependencies = [\"requests==2.0.0\"]\n";
448            let mut cached = HashMap::new();
449            cached.insert("requests".into(), PackageVersions::latest_only("2.5.0"));
450
451            assert_single_edit_produces_valid_declaration(
452                ecosystem.as_ref(),
453                &uri,
454                content,
455                cached,
456                "requests==2.5.0",
457            )
458            .await;
459        }
460
461        #[cfg(feature = "go")]
462        #[tokio::test]
463        async fn test_go_literal_version_is_edited() {
464            let state = ServerState::new();
465            let ecosystem = state.ecosystem_registry.get("go").unwrap();
466            let uri = deps_core::test_util::test_uri("/test/go.mod");
467            let content =
468                "module example.com/myapp\n\ngo 1.21\n\nrequire github.com/gin-gonic/gin v1.9.1\n";
469            let mut cached = HashMap::new();
470            cached.insert(
471                "github.com/gin-gonic/gin".into(),
472                PackageVersions::latest_only("v1.10.0"),
473            );
474
475            assert_single_edit_produces_valid_declaration(
476                ecosystem.as_ref(),
477                &uri,
478                content,
479                cached,
480                "require github.com/gin-gonic/gin v1.10.0",
481            )
482            .await;
483        }
484
485        #[cfg(feature = "dart")]
486        #[tokio::test]
487        async fn test_dart_literal_version_is_edited() {
488            let state = ServerState::new();
489            let ecosystem = state.ecosystem_registry.get("dart").unwrap();
490            let uri = deps_core::test_util::test_uri("/test/pubspec.yaml");
491            // A major-version bump: "^1.0.0" does not accept "2.0.0".
492            let content = "dependencies:\n  http: ^1.0.0\n";
493            let mut cached = HashMap::new();
494            cached.insert("http".into(), PackageVersions::latest_only("2.0.0"));
495
496            assert_single_edit_produces_valid_declaration(
497                ecosystem.as_ref(),
498                &uri,
499                content,
500                cached,
501                "http: ^2.0.0",
502            )
503            .await;
504        }
505
506        #[cfg(feature = "nuget")]
507        #[tokio::test]
508        async fn test_nuget_literal_version_is_edited() {
509            let state = ServerState::new();
510            let ecosystem = state.ecosystem_registry.get("nuget").unwrap();
511            let uri = deps_core::test_util::test_uri("/test/project.csproj");
512            let content = r#"<Project><ItemGroup><PackageReference Include="Newtonsoft.Json" Version="12.0.3" /></ItemGroup></Project>"#;
513            let mut cached = HashMap::new();
514            cached.insert(
515                "Newtonsoft.Json".into(),
516                PackageVersions::latest_only("13.0.3"),
517            );
518
519            assert_single_edit_produces_valid_declaration(
520                ecosystem.as_ref(),
521                &uri,
522                content,
523                cached,
524                r#"Version="13.0.3""#,
525            )
526            .await;
527        }
528
529        #[cfg(feature = "composer")]
530        #[tokio::test]
531        async fn test_composer_literal_version_is_edited() {
532            let state = ServerState::new();
533            let ecosystem = state.ecosystem_registry.get("composer").unwrap();
534            let uri = deps_core::test_util::test_uri("/test/composer.json");
535            let content = "{\n  \"require\": {\n    \"symfony/console\": \"^6.0\"\n  }\n}";
536            let mut cached = HashMap::new();
537            cached.insert(
538                "symfony/console".into(),
539                PackageVersions::latest_only("7.0.0"),
540            );
541
542            assert_single_edit_produces_valid_declaration(
543                ecosystem.as_ref(),
544                &uri,
545                content,
546                cached,
547                "\"symfony/console\": \"7.0.0\"",
548            )
549            .await;
550        }
551
552        #[cfg(feature = "bundler")]
553        #[tokio::test]
554        async fn test_bundler_literal_version_is_edited() {
555            let state = ServerState::new();
556            let ecosystem = state.ecosystem_registry.get("bundler").unwrap();
557            let uri = deps_core::test_util::test_uri("/test/Gemfile");
558            let content = "source 'https://rubygems.org'\ngem 'rails', '~> 7.0'";
559            let mut cached = HashMap::new();
560            cached.insert("rails".into(), PackageVersions::latest_only("8.0.0"));
561
562            assert_single_edit_produces_valid_declaration(
563                ecosystem.as_ref(),
564                &uri,
565                content,
566                cached,
567                "gem 'rails', '8.0.0'",
568            )
569            .await;
570        }
571
572        #[cfg(feature = "maven")]
573        #[tokio::test]
574        async fn test_maven_literal_version_is_edited() {
575            let state = ServerState::new();
576            let ecosystem = state.ecosystem_registry.get("maven").unwrap();
577            let uri = deps_core::test_util::test_uri("/test/pom.xml");
578            let content = r"<project>
579  <dependencies>
580    <dependency>
581      <groupId>org.apache.commons</groupId>
582      <artifactId>commons-lang3</artifactId>
583      <version>3.12.0</version>
584    </dependency>
585  </dependencies>
586</project>
587";
588            let mut cached = HashMap::new();
589            cached.insert(
590                "org.apache.commons:commons-lang3".into(),
591                PackageVersions::latest_only("3.14.0"),
592            );
593
594            assert_single_edit_produces_valid_declaration(
595                ecosystem.as_ref(),
596                &uri,
597                content,
598                cached,
599                "<version>3.14.0</version>",
600            )
601            .await;
602        }
603
604        #[cfg(feature = "maven")]
605        #[tokio::test]
606        async fn test_maven_property_version_is_skipped() {
607            let state = ServerState::new();
608            let ecosystem = state.ecosystem_registry.get("maven").unwrap();
609            let uri = deps_core::test_util::test_uri("/test/pom.xml");
610            let content = r"<project>
611  <properties>
612    <slf4j.version>2.0.16</slf4j.version>
613  </properties>
614  <dependencies>
615    <dependency>
616      <groupId>org.slf4j</groupId>
617      <artifactId>slf4j-api</artifactId>
618      <version>${slf4j.version}</version>
619    </dependency>
620  </dependencies>
621</project>
622";
623            let mut cached = HashMap::new();
624            cached.insert(
625                "org.slf4j:slf4j-api".into(),
626                PackageVersions::latest_only("2.1.0"),
627            );
628
629            assert_guard_skips(ecosystem.as_ref(), &uri, content, cached).await;
630        }
631
632        #[cfg(feature = "gradle")]
633        #[tokio::test]
634        async fn test_gradle_dsl_variable_is_skipped() {
635            // Gradle resolves `$var`/`${var}` references only from a real
636            // `gradle.properties` file next to the build script, so this fixture is
637            // written to a temp directory (mirrors `document::lifecycle`'s own
638            // disk-based cold-start tests).
639            let temp_dir = tempfile::TempDir::new().unwrap();
640            let build_gradle_path = temp_dir.path().join("build.gradle");
641            let content = "dependencies {\n    implementation \"org.jetbrains.kotlin:kotlin-stdlib:$kotlinVersion\"\n}\n";
642            std::fs::write(&build_gradle_path, content).unwrap();
643            std::fs::write(
644                temp_dir.path().join("gradle.properties"),
645                "kotlinVersion=2.1.10\n",
646            )
647            .unwrap();
648
649            let uri = tower_lsp_server::ls_types::Uri::from_file_path(&build_gradle_path).unwrap();
650            let state = ServerState::new();
651            let ecosystem = state.ecosystem_registry.get("gradle").unwrap();
652
653            let mut cached = HashMap::new();
654            cached.insert(
655                "org.jetbrains.kotlin:kotlin-stdlib".into(),
656                PackageVersions::latest_only("2.2.0"),
657            );
658
659            assert_guard_skips(ecosystem.as_ref(), &uri, content, cached).await;
660        }
661
662        #[cfg(feature = "gradle")]
663        #[tokio::test]
664        async fn test_gradle_version_catalog_alias_is_skipped() {
665            let state = ServerState::new();
666            let ecosystem = state.ecosystem_registry.get("gradle").unwrap();
667            let uri = deps_core::test_util::test_uri("/test/gradle/libs.versions.toml");
668            let content = "[versions]\nspring = \"3.2.0\"\n\n[libraries]\nspring-boot = { module = \"org.springframework.boot:spring-boot-starter\", version.ref = \"spring\" }\n";
669            let mut cached = HashMap::new();
670            cached.insert(
671                "org.springframework.boot:spring-boot-starter".into(),
672                PackageVersions::latest_only("3.3.0"),
673            );
674
675            assert_guard_skips(ecosystem.as_ref(), &uri, content, cached).await;
676        }
677
678        #[cfg(feature = "swift")]
679        #[tokio::test]
680        async fn test_swift_from_form_is_edited() {
681            // Regression for #367: `version_literal` now lets the literal-span guard
682            // match a Swift dependency's synthesized comparator requirement against the
683            // bare literal `version_range` spans, so this case — previously always
684            // skipped regardless of ecosystem-independent test naming — now produces an
685            // edit like every other registry-form dependency.
686            let state = ServerState::new();
687            let ecosystem = state.ecosystem_registry.get("swift").unwrap();
688            let uri = deps_core::test_util::test_uri("/test/Package.swift");
689            let content = r#"
690let package = Package(
691    dependencies: [
692        .package(url: "https://github.com/apple/swift-nio.git", from: "2.40.0"),
693    ]
694)
695"#;
696            let mut cached = HashMap::new();
697            cached.insert(
698                "apple/swift-nio".into(),
699                PackageVersions::latest_only("3.0.0"),
700            );
701
702            assert_single_edit_produces_valid_declaration(
703                ecosystem.as_ref(),
704                &uri,
705                content,
706                cached,
707                "3.0.0",
708            )
709            .await;
710        }
711
712        #[cfg(feature = "swift")]
713        #[tokio::test]
714        async fn test_swift_range_forms_are_still_skipped() {
715            // Regression for #367 critic finding C1: `version_range` for a `..<`/`...`
716            // dependency spans only the lower-bound literal. If the guard were fooled
717            // into accepting that as `version_literal`, the edit would rewrite the lower
718            // bound alone and invert the range — SwiftPM traps on `lowerBound >
719            // upperBound`. `version_literal` stays `None` for both range forms, so this
720            // must keep producing zero edits, matching the pre-#367-fix behavior for
721            // every other unsupported-literal case (Maven `${property}`, Gradle DSL var).
722            let state = ServerState::new();
723            let ecosystem = state.ecosystem_registry.get("swift").unwrap();
724            let uri = deps_core::test_util::test_uri("/test/Package.swift");
725
726            for content in [
727                r#".package(url: "https://github.com/foo/bar", "1.0.0"..<"2.0.0")"#,
728                r#".package(url: "https://github.com/baz/qux", "1.0.0"..."1.9.9")"#,
729            ] {
730                let mut cached = HashMap::new();
731                cached.insert("foo/bar".into(), PackageVersions::latest_only("3.5.0"));
732                cached.insert("baz/qux".into(), PackageVersions::latest_only("3.5.0"));
733
734                assert_guard_skips(ecosystem.as_ref(), &uri, content, cached).await;
735            }
736        }
737
738        /// Seeds `ecosystem`'s shared `TagIndex` for `name` with one `tag -> sha` entry,
739        /// downcasting through `Ecosystem::registry()`/`Registry::as_any()` — this test
740        /// module never drives a live registry fetch (`collect_update_all_edits` is pure
741        /// over `parse_result`/`content`/cached `VersionData`), so the index has to be
742        /// seeded directly for the SHA-pin `format_version_replacing_for` branch to have
743        /// anything to resolve.
744        #[cfg(feature = "github-actions")]
745        fn seed_gha_tag_index(
746            ecosystem: &dyn deps_core::Ecosystem,
747            name: &str,
748            tag: &str,
749            sha: &str,
750        ) {
751            let registry = ecosystem.registry();
752            let gha_registry = registry
753                .as_any()
754                .downcast_ref::<deps_github_actions::GithubActionsRegistry>()
755                .expect("github-actions ecosystem must back onto a GithubActionsRegistry");
756            let mut index = deps_github_actions::registry::TagIndex::default();
757            index.tag_to_sha.insert(tag.to_string(), sha.to_string());
758            index.sha_to_tag.insert(sha.to_string(), tag.to_string());
759            gha_registry.tag_index().insert(
760                deps_core::PackageName::new(name),
761                std::sync::Arc::new(index),
762            );
763        }
764
765        #[cfg(feature = "github-actions")]
766        #[tokio::test]
767        async fn test_github_actions_tag_pin_is_edited() {
768            let state = ServerState::new();
769            let ecosystem = state.ecosystem_registry.get("github-actions").unwrap();
770            let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
771            let content = "steps:\n  - uses: actions/checkout@v4.2.0\n";
772            let mut cached = HashMap::new();
773            cached.insert(
774                "actions/checkout".into(),
775                PackageVersions::latest_only("v4.3.0"),
776            );
777
778            assert_single_edit_produces_valid_declaration(
779                ecosystem.as_ref(),
780                &uri,
781                content,
782                cached,
783                "v4.3.0",
784            )
785            .await;
786        }
787
788        #[cfg(feature = "github-actions")]
789        #[tokio::test]
790        async fn test_github_actions_sha_with_comment_pin_is_edited_to_new_sha_and_tag() {
791            let state = ServerState::new();
792            let ecosystem = state.ecosystem_registry.get("github-actions").unwrap();
793            let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
794            let old_sha = "a".repeat(40);
795            let new_sha = "b".repeat(40);
796            seed_gha_tag_index(ecosystem.as_ref(), "actions/checkout", "v4.3.0", &new_sha);
797
798            let content = format!("steps:\n  - uses: actions/checkout@{old_sha} # v4.2.0\n");
799            let mut cached = HashMap::new();
800            cached.insert(
801                "actions/checkout".into(),
802                PackageVersions::latest_only("v4.3.0"),
803            );
804
805            assert_single_edit_produces_valid_declaration(
806                ecosystem.as_ref(),
807                &uri,
808                &content,
809                cached,
810                &format!("{new_sha} # v4.3.0"),
811            )
812            .await;
813        }
814
815        #[cfg(feature = "github-actions")]
816        #[tokio::test]
817        async fn test_github_actions_subdirectory_action_sha_pin_is_edited_preserving_subpath() {
818            // Critic S1 regression gate: a subdirectory action's `version_range` must
819            // start right after the full `owner/repo/sub@` prefix, not after the
820            // truncated `owner/repo@` — otherwise this edit corrupts the `/init@`
821            // segment, producing a re-parseable-but-wrong bare `owner/repo` declaration
822            // with the pin silently deleted.
823            let state = ServerState::new();
824            let ecosystem = state.ecosystem_registry.get("github-actions").unwrap();
825            let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
826            let old_sha = "a".repeat(40);
827            let new_sha = "b".repeat(40);
828            seed_gha_tag_index(
829                ecosystem.as_ref(),
830                "github/codeql-action",
831                "v3.1.0",
832                &new_sha,
833            );
834
835            let content =
836                format!("steps:\n  - uses: github/codeql-action/init@{old_sha} # v3.0.0\n");
837            let mut cached = HashMap::new();
838            cached.insert(
839                "github/codeql-action".into(),
840                PackageVersions::latest_only("v3.1.0"),
841            );
842
843            assert_single_edit_produces_valid_declaration(
844                ecosystem.as_ref(),
845                &uri,
846                &content,
847                cached,
848                &format!("codeql-action/init@{new_sha} # v3.1.0"),
849            )
850            .await;
851        }
852
853        #[cfg(feature = "github-actions")]
854        #[tokio::test]
855        async fn test_github_actions_sha_pin_tag_index_miss_is_skipped() {
856            // B1's regression gate: on a `TagIndex` miss, the formatted replacement must
857            // equal the raw declared span byte-for-byte, so the shared no-op guard
858            // suppresses the edit instead of silently downgrading the SHA pin to a bare
859            // tag.
860            let state = ServerState::new();
861            let ecosystem = state.ecosystem_registry.get("github-actions").unwrap();
862            let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
863            let old_sha = "a".repeat(40);
864            // No `seed_gha_tag_index` call: the index has no entry for "v4.3.0", so the
865            // formatter's `TagIndex` lookup misses.
866
867            let content = format!("steps:\n  - uses: actions/checkout@{old_sha} # v4.2.0\n");
868            let mut cached = HashMap::new();
869            cached.insert(
870                "actions/checkout".into(),
871                PackageVersions::latest_only("v4.3.0"),
872            );
873
874            assert_guard_skips(ecosystem.as_ref(), &uri, &content, cached).await;
875        }
876    }
877}