diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d8435f3..a0afd43b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] - 2025-12-26 + +### Added +- **Go modules support** — Full ecosystem support for Go packages (`deps-go` crate) + - go.mod parser with position tracking for all directives + - go.sum lock file parser for resolved versions + - Support for `require`, `replace`, `exclude` directives + - Indirect dependency detection (`// indirect` comments) + - Pseudo-version parsing and display + - proxy.golang.org registry client with HTTP caching + - Module path escaping for uppercase characters + - Inlay hints, hover, code actions, diagnostics +- Lockfile template added to ecosystem templates +- Formatter template added to ecosystem templates + +### Changed +- **Feature flags for ecosystems** — Each ecosystem can now be enabled/disabled independently + - `cargo` — Cargo.toml support (default: enabled) + - `npm` — package.json support (default: enabled) + - `pypi` — pyproject.toml support (default: enabled) + - `go` — go.mod support (default: enabled) +- Updated ECOSYSTEM_GUIDE.md with Go examples and lockfile/formatter requirements +- Templates now include lockfile.rs.template and formatter.rs.template + ## [0.4.1] - 2025-12-26 ### Added @@ -174,7 +198,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - TLS enforced via rustls - cargo-deny configured for vulnerability scanning -[Unreleased]: https://github.com/bug-ops/deps-lsp/compare/v0.4.1...HEAD +[Unreleased]: https://github.com/bug-ops/deps-lsp/compare/v0.5.0...HEAD +[0.5.0]: https://github.com/bug-ops/deps-lsp/compare/v0.4.1...v0.5.0 [0.4.1]: https://github.com/bug-ops/deps-lsp/compare/v0.4.0...v0.4.1 [0.4.0]: https://github.com/bug-ops/deps-lsp/compare/v0.3.1...v0.4.0 [0.3.1]: https://github.com/bug-ops/deps-lsp/compare/v0.3.0...v0.3.1 diff --git a/Cargo.lock b/Cargo.lock index 97f5582d..f4f924a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -357,7 +357,7 @@ dependencies = [ [[package]] name = "deps-cargo" -version = "0.4.1" +version = "0.5.0" dependencies = [ "async-trait", "criterion", @@ -379,7 +379,7 @@ dependencies = [ [[package]] name = "deps-core" -version = "0.4.1" +version = "0.5.0" dependencies = [ "async-trait", "bytes", @@ -402,7 +402,7 @@ dependencies = [ [[package]] name = "deps-go" -version = "0.4.1" +version = "0.5.0" dependencies = [ "async-trait", "deps-core", @@ -421,7 +421,7 @@ dependencies = [ [[package]] name = "deps-lsp" -version = "0.4.1" +version = "0.5.0" dependencies = [ "criterion", "dashmap", @@ -446,7 +446,7 @@ dependencies = [ [[package]] name = "deps-npm" -version = "0.4.1" +version = "0.5.0" dependencies = [ "async-trait", "criterion", @@ -465,7 +465,7 @@ dependencies = [ [[package]] name = "deps-pypi" -version = "0.4.1" +version = "0.5.0" dependencies = [ "async-trait", "criterion", diff --git a/Cargo.toml b/Cargo.toml index f6df9470..1225071d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ exclude = ["crates/deps-zed"] resolver = "2" [workspace.package] -version = "0.4.1" +version = "0.5.0" edition = "2024" rust-version = "1.89" authors = ["Andrei G"] @@ -15,12 +15,12 @@ repository = "https://github.com/bug-ops/deps-lsp" async-trait = "0.1" criterion = "0.8" dashmap = "6.1" -deps-core = { version = "0.4.1", path = "crates/deps-core" } -deps-cargo = { version = "0.4.1", path = "crates/deps-cargo" } -deps-npm = { version = "0.4.1", path = "crates/deps-npm" } -deps-pypi = { version = "0.4.1", path = "crates/deps-pypi" } -deps-go = { version = "0.4.1", path = "crates/deps-go" } -deps-lsp = { version = "0.4.1", path = "crates/deps-lsp" } +deps-core = { version = "0.5.0", path = "crates/deps-core" } +deps-cargo = { version = "0.5.0", path = "crates/deps-cargo" } +deps-npm = { version = "0.5.0", path = "crates/deps-npm" } +deps-pypi = { version = "0.5.0", path = "crates/deps-pypi" } +deps-go = { version = "0.5.0", path = "crates/deps-go" } +deps-lsp = { version = "0.5.0", path = "crates/deps-lsp" } futures = "0.3" insta = "1" mockito = "1" diff --git a/README.md b/README.md index f9f7725c..0b687960 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,25 @@ Download from [GitHub Releases](https://github.com/bug-ops/deps-lsp/releases/lat | macOS | Apple Silicon | `deps-lsp-aarch64-apple-darwin` | | Windows | x86_64 | `deps-lsp-x86_64-pc-windows-msvc.exe` | +### Feature Flags + +By default, all ecosystems are enabled. To build with specific ecosystems only: + +```bash +# Only Cargo and npm support +cargo install deps-lsp --no-default-features --features "cargo,npm" + +# Only Python support +cargo install deps-lsp --no-default-features --features "pypi" +``` + +| Feature | Ecosystem | Default | +|---------|-----------|---------| +| `cargo` | Cargo.toml | ✅ | +| `npm` | package.json | ✅ | +| `pypi` | pyproject.toml | ✅ | +| `go` | go.mod | ✅ | + ## Editor Setup ### Zed diff --git a/crates/deps-cargo/README.md b/crates/deps-cargo/README.md index 79ea0415..fb85089d 100644 --- a/crates/deps-cargo/README.md +++ b/crates/deps-cargo/README.md @@ -22,7 +22,7 @@ This crate provides parsing and registry integration for Rust's Cargo ecosystem. ```toml [dependencies] -deps-cargo = "0.4" +deps-cargo = "0.5" ``` ```rust diff --git a/crates/deps-core/README.md b/crates/deps-core/README.md index 337cb341..0599c483 100644 --- a/crates/deps-core/README.md +++ b/crates/deps-core/README.md @@ -22,7 +22,7 @@ This crate provides the shared infrastructure used by ecosystem-specific crates ```toml [dependencies] -deps-core = "0.4" +deps-core = "0.5" ``` ```rust diff --git a/crates/deps-go/README.md b/crates/deps-go/README.md index fb1c94d1..16e05048 100644 --- a/crates/deps-go/README.md +++ b/crates/deps-go/README.md @@ -12,6 +12,7 @@ This crate provides parsing and registry integration for Go's module ecosystem. ## Features - **go.mod Parsing** — Parse `go.mod` with position tracking for all directives +- **go.sum Lock File** — Extract resolved versions from `go.sum` - **Directive Support** — Handle `require`, `replace`, `exclude`, and `retract` directives - **Indirect Dependencies** — Detect and mark indirect dependencies (`// indirect`) - **Pseudo-versions** — Parse and validate Go pseudo-version format @@ -23,7 +24,7 @@ This crate provides parsing and registry integration for Go's module ecosystem. ```toml [dependencies] -deps-go = "0.4" +deps-go = "0.5" ``` ```rust diff --git a/crates/deps-lsp/Cargo.toml b/crates/deps-lsp/Cargo.toml index 518a3c6e..4dae83ed 100644 --- a/crates/deps-lsp/Cargo.toml +++ b/crates/deps-lsp/Cargo.toml @@ -17,13 +17,20 @@ path = "src/main.rs" name = "deps_lsp" path = "src/lib.rs" +[features] +default = ["cargo", "npm", "pypi", "go"] +cargo = ["dep:deps-cargo"] +npm = ["dep:deps-npm"] +pypi = ["dep:deps-pypi"] +go = ["dep:deps-go"] + [dependencies] # Internal crates deps-core = { workspace = true } -deps-cargo = { workspace = true } -deps-npm = { workspace = true } -deps-pypi = { workspace = true } -deps-go = { workspace = true } +deps-cargo = { workspace = true, optional = true } +deps-npm = { workspace = true, optional = true } +deps-pypi = { workspace = true, optional = true } +deps-go = { workspace = true, optional = true } # External dependencies dashmap = { workspace = true } diff --git a/crates/deps-lsp/src/document/lifecycle.rs b/crates/deps-lsp/src/document/lifecycle.rs index 8f211633..0a63cffa 100644 --- a/crates/deps-lsp/src/document/lifecycle.rs +++ b/crates/deps-lsp/src/document/lifecycle.rs @@ -430,277 +430,356 @@ pub async fn ensure_document_loaded( mod tests { use super::*; + // Generic tests (no feature flag required) + #[test] - fn test_ecosystem_registry_lookup() { + fn test_ecosystem_registry_unknown_file() { let state = ServerState::new(); - - let cargo_uri = - tower_lsp_server::ls_types::Uri::from_file_path("/test/Cargo.toml").unwrap(); - assert!(state.ecosystem_registry.get_for_uri(&cargo_uri).is_some()); - - let npm_uri = - tower_lsp_server::ls_types::Uri::from_file_path("/test/package.json").unwrap(); - assert!(state.ecosystem_registry.get_for_uri(&npm_uri).is_some()); - - let pypi_uri = - tower_lsp_server::ls_types::Uri::from_file_path("/test/pyproject.toml").unwrap(); - assert!(state.ecosystem_registry.get_for_uri(&pypi_uri).is_some()); - let unknown_uri = tower_lsp_server::ls_types::Uri::from_file_path("/test/unknown.txt").unwrap(); assert!(state.ecosystem_registry.get_for_uri(&unknown_uri).is_none()); } #[tokio::test] - async fn test_document_parsing_cargo() { + async fn test_ensure_document_loaded_unsupported_file_check() { + // Returns false for unknown file types (e.g., README.md) let state = Arc::new(ServerState::new()); - let uri = tower_lsp_server::ls_types::Uri::from_file_path("/test/Cargo.toml").unwrap(); - let content = r#"[dependencies] -serde = "1.0" -"#; - - let ecosystem = state - .ecosystem_registry - .get_for_uri(&uri) - .expect("Cargo ecosystem not found"); - - let parse_result = ecosystem.parse_manifest(content, &uri).await; - assert!(parse_result.is_ok()); + let uri = Uri::from_file_path("/test/README.md").unwrap(); - let doc_state = DocumentState::new_from_parse_result( - "cargo", - content.to_string(), - parse_result.unwrap(), + // Verify ecosystem registry correctly identifies unsupported files + assert!( + state.ecosystem_registry.get_for_uri(&uri).is_none(), + "README.md should not have an ecosystem handler" ); - state.update_document(uri.clone(), doc_state); - assert_eq!(state.document_count(), 1); - let doc = state.get_document(&uri).unwrap(); - assert_eq!(doc.ecosystem_id, "cargo"); + // This would cause ensure_document_loaded to return false + // We test the underlying condition without needing Client } #[tokio::test] - async fn test_document_parsing_npm() { - let state = Arc::new(ServerState::new()); - let uri = tower_lsp_server::ls_types::Uri::from_file_path("/test/package.json").unwrap(); - let content = r#"{"dependencies": {"express": "^4.18.0"}}"#; - - let ecosystem = state - .ecosystem_registry - .get_for_uri(&uri) - .expect("npm ecosystem not found"); + async fn test_ensure_document_loaded_file_not_found_check() { + // Test that load_document_from_disk fails gracefully for missing files + use super::load_document_from_disk; - let parse_result = ecosystem.parse_manifest(content, &uri).await; - assert!(parse_result.is_ok()); + let uri = Uri::from_file_path("/nonexistent/Cargo.toml").unwrap(); + let result = load_document_from_disk(&uri).await; - let doc_state = - DocumentState::new_from_parse_result("npm", content.to_string(), parse_result.unwrap()); - state.update_document(uri.clone(), doc_state); + assert!(result.is_err(), "Should fail for missing files"); - let doc = state.get_document(&uri).unwrap(); - assert_eq!(doc.ecosystem_id, "npm"); + // This error would cause ensure_document_loaded to return false } - #[tokio::test] - async fn test_document_parsing_pypi() { - let state = Arc::new(ServerState::new()); - let uri = tower_lsp_server::ls_types::Uri::from_file_path("/test/pyproject.toml").unwrap(); - let content = r#"[project] -dependencies = ["requests>=2.0.0"] + // Cargo-specific tests + #[cfg(feature = "cargo")] + mod cargo_tests { + use super::*; + + #[test] + fn test_ecosystem_registry_lookup() { + let state = ServerState::new(); + let cargo_uri = + tower_lsp_server::ls_types::Uri::from_file_path("/test/Cargo.toml").unwrap(); + assert!(state.ecosystem_registry.get_for_uri(&cargo_uri).is_some()); + } + + #[tokio::test] + async fn test_document_parsing() { + let state = Arc::new(ServerState::new()); + let uri = tower_lsp_server::ls_types::Uri::from_file_path("/test/Cargo.toml").unwrap(); + let content = r#"[dependencies] +serde = "1.0" "#; - let ecosystem = state - .ecosystem_registry - .get_for_uri(&uri) - .expect("pypi ecosystem not found"); + let ecosystem = state + .ecosystem_registry + .get_for_uri(&uri) + .expect("Cargo ecosystem not found"); - let parse_result = ecosystem.parse_manifest(content, &uri).await; - assert!(parse_result.is_ok()); + let parse_result = ecosystem.parse_manifest(content, &uri).await; + assert!(parse_result.is_ok()); - let doc_state = DocumentState::new_from_parse_result( - "pypi", - content.to_string(), - parse_result.unwrap(), - ); - state.update_document(uri.clone(), doc_state); + let doc_state = DocumentState::new_from_parse_result( + "cargo", + content.to_string(), + parse_result.unwrap(), + ); + state.update_document(uri.clone(), doc_state); - let doc = state.get_document(&uri).unwrap(); - assert_eq!(doc.ecosystem_id, "pypi"); - } + assert_eq!(state.document_count(), 1); + let doc = state.get_document(&uri).unwrap(); + assert_eq!(doc.ecosystem_id, "cargo"); + } - #[tokio::test] - async fn test_document_stored_even_when_parsing_fails() { - let state = Arc::new(ServerState::new()); - let uri = tower_lsp_server::ls_types::Uri::from_file_path("/test/Cargo.toml").unwrap(); - // Invalid TOML that will fail parsing - let content = r#"[dependencies + #[tokio::test] + async fn test_document_stored_even_when_parsing_fails() { + let state = Arc::new(ServerState::new()); + let uri = tower_lsp_server::ls_types::Uri::from_file_path("/test/Cargo.toml").unwrap(); + // Invalid TOML that will fail parsing + let content = r#"[dependencies serde = "1.0" "#; - let ecosystem = state - .ecosystem_registry - .get_for_uri(&uri) - .expect("Cargo ecosystem not found"); + let ecosystem = state + .ecosystem_registry + .get_for_uri(&uri) + .expect("Cargo ecosystem not found"); - // Try to parse (will fail) - let parse_result = ecosystem.parse_manifest(content, &uri).await.ok(); - assert!( - parse_result.is_none(), - "Parsing should fail for invalid TOML" - ); + // Try to parse (will fail) + let parse_result = ecosystem.parse_manifest(content, &uri).await.ok(); + assert!( + parse_result.is_none(), + "Parsing should fail for invalid TOML" + ); - // Create document state without parse result - let doc_state = if let Some(pr) = parse_result { - DocumentState::new_from_parse_result("cargo", content.to_string(), pr) - } else { - DocumentState::new_without_parse_result("cargo", content.to_string()) - }; + // Create document state without parse result + let doc_state = if let Some(pr) = parse_result { + DocumentState::new_from_parse_result("cargo", content.to_string(), pr) + } else { + DocumentState::new_without_parse_result("cargo", content.to_string()) + }; - state.update_document(uri.clone(), doc_state); + state.update_document(uri.clone(), doc_state); - // Document should be stored despite parse failure - let doc = state.get_document(&uri); - assert!( - doc.is_some(), - "Document should be stored even when parsing fails" - ); + // Document should be stored despite parse failure + let doc = state.get_document(&uri); + assert!( + doc.is_some(), + "Document should be stored even when parsing fails" + ); - let doc = doc.unwrap(); - assert_eq!(doc.ecosystem_id, "cargo"); - assert_eq!(doc.content, content); - assert!( - doc.parse_result().is_none(), - "Parse result should be None for failed parse" - ); - } + let doc = doc.unwrap(); + assert_eq!(doc.ecosystem_id, "cargo"); + assert_eq!(doc.content, content); + assert!( + doc.parse_result().is_none(), + "Parse result should be None for failed parse" + ); + } - #[tokio::test] - async fn test_ensure_document_loaded_fast_path() { - // Fast path: document already loaded, should return true without loading - let state = Arc::new(ServerState::new()); - let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); - let content = r#"[dependencies] + #[tokio::test] + async fn test_ensure_document_loaded_fast_path() { + // Fast path: document already loaded, should return true without loading + let state = Arc::new(ServerState::new()); + let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); + let content = r#"[dependencies] serde = "1.0""#; - // Pre-populate state with document - let ecosystem = state - .ecosystem_registry - .get_for_uri(&uri) - .expect("Cargo ecosystem"); - let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap(); - let doc_state = - DocumentState::new_from_parse_result("cargo", content.to_string(), parse_result); - state.update_document(uri.clone(), doc_state); - - // Fast path check: document exists - assert!( - state.get_document(&uri).is_some(), - "Document should exist in state" - ); - assert_eq!(state.document_count(), 1, "Document count should be 1"); + // Pre-populate state with document + let ecosystem = state + .ecosystem_registry + .get_for_uri(&uri) + .expect("Cargo ecosystem"); + let parse_result = ecosystem.parse_manifest(content, &uri).await.unwrap(); + let doc_state = + DocumentState::new_from_parse_result("cargo", content.to_string(), parse_result); + state.update_document(uri.clone(), doc_state); + + // Fast path check: document exists + assert!( + state.get_document(&uri).is_some(), + "Document should exist in state" + ); + assert_eq!(state.document_count(), 1, "Document count should be 1"); - // The fast path in ensure_document_loaded would return true here without - // requiring a Client. We test the condition directly since creating a test - // Client requires complex tower-lsp-server internals (ServerState, ClientSocket). - } + // The fast path in ensure_document_loaded would return true here without + // requiring a Client. We test the condition directly since creating a test + // Client requires complex tower-lsp-server internals (ServerState, ClientSocket). + } - #[tokio::test] - async fn test_ensure_document_loaded_unsupported_file_check() { - // Returns false for unknown file types (e.g., README.md) - let state = Arc::new(ServerState::new()); - let uri = Uri::from_file_path("/test/README.md").unwrap(); + #[tokio::test] + async fn test_ensure_document_loaded_successful_disk_load() { + // Test successful load from filesystem with temp file + use super::super::load_document_from_disk; + use std::fs; + use tempfile::TempDir; + + // Create a temporary directory with a Cargo.toml file + let temp_dir = TempDir::new().unwrap(); + let cargo_toml_path = temp_dir.path().join("Cargo.toml"); + let content = r#"[package] +name = "test" +version = "0.1.0" - // Verify ecosystem registry correctly identifies unsupported files - assert!( - state.ecosystem_registry.get_for_uri(&uri).is_none(), - "README.md should not have an ecosystem handler" - ); +[dependencies] +serde = "1.0" +"#; + fs::write(&cargo_toml_path, content).unwrap(); - // This would cause ensure_document_loaded to return false - // We test the underlying condition without needing Client - } + let uri = Uri::from_file_path(&cargo_toml_path).unwrap(); - #[tokio::test] - async fn test_ensure_document_loaded_file_not_found_check() { - // Test that load_document_from_disk fails gracefully for missing files - use super::load_document_from_disk; + // Test that load_document_from_disk succeeds + let loaded_content = load_document_from_disk(&uri).await.unwrap(); + assert_eq!(loaded_content, content); - let uri = Uri::from_file_path("/nonexistent/Cargo.toml").unwrap(); - let result = load_document_from_disk(&uri).await; + // Test that parsing succeeds + let state = Arc::new(ServerState::new()); + let ecosystem = state + .ecosystem_registry + .get_for_uri(&uri) + .expect("Cargo ecosystem"); + let parse_result = ecosystem.parse_manifest(&loaded_content, &uri).await; + assert!(parse_result.is_ok(), "Should parse successfully"); - assert!(result.is_err(), "Should fail for missing files"); + // These successful operations are the building blocks of ensure_document_loaded + } - // This error would cause ensure_document_loaded to return false + #[tokio::test] + async fn test_ensure_document_loaded_idempotent_check() { + // Test that repeated loads are idempotent at the state level + let state = Arc::new(ServerState::new()); + let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); + let content = r#"[dependencies] +serde = "1.0""#; + + let ecosystem = state + .ecosystem_registry + .get_for_uri(&uri) + .expect("Cargo ecosystem"); + + // Parse twice to simulate idempotent loads + let parse_result1 = ecosystem.parse_manifest(content, &uri).await.unwrap(); + let parse_result2 = ecosystem.parse_manifest(content, &uri).await.unwrap(); + + // First update + let doc_state1 = + DocumentState::new_from_parse_result("cargo", content.to_string(), parse_result1); + state.update_document(uri.clone(), doc_state1); + assert_eq!(state.document_count(), 1); + + // Second update (idempotent) + let doc_state2 = + DocumentState::new_from_parse_result("cargo", content.to_string(), parse_result2); + state.update_document(uri.clone(), doc_state2); + assert_eq!( + state.document_count(), + 1, + "Should still have only 1 document" + ); + } } - #[tokio::test] - async fn test_ensure_document_loaded_successful_disk_load() { - // Test successful load from filesystem with temp file - use super::load_document_from_disk; - use std::fs; - use tempfile::TempDir; + // npm-specific tests + #[cfg(feature = "npm")] + mod npm_tests { + use super::*; + + #[test] + fn test_ecosystem_registry_lookup() { + let state = ServerState::new(); + let npm_uri = + tower_lsp_server::ls_types::Uri::from_file_path("/test/package.json").unwrap(); + assert!(state.ecosystem_registry.get_for_uri(&npm_uri).is_some()); + } - // Create a temporary directory with a Cargo.toml file - let temp_dir = TempDir::new().unwrap(); - let cargo_toml_path = temp_dir.path().join("Cargo.toml"); - let content = r#"[package] -name = "test" -version = "0.1.0" + #[tokio::test] + async fn test_document_parsing() { + let state = Arc::new(ServerState::new()); + let uri = + tower_lsp_server::ls_types::Uri::from_file_path("/test/package.json").unwrap(); + let content = r#"{"dependencies": {"express": "^4.18.0"}}"#; + + let ecosystem = state + .ecosystem_registry + .get_for_uri(&uri) + .expect("npm ecosystem not found"); + + let parse_result = ecosystem.parse_manifest(content, &uri).await; + assert!(parse_result.is_ok()); + + let doc_state = DocumentState::new_from_parse_result( + "npm", + content.to_string(), + parse_result.unwrap(), + ); + state.update_document(uri.clone(), doc_state); -[dependencies] -serde = "1.0" + let doc = state.get_document(&uri).unwrap(); + assert_eq!(doc.ecosystem_id, "npm"); + } + } + + // PyPI-specific tests + #[cfg(feature = "pypi")] + mod pypi_tests { + use super::*; + + #[test] + fn test_ecosystem_registry_lookup() { + let state = ServerState::new(); + let pypi_uri = + tower_lsp_server::ls_types::Uri::from_file_path("/test/pyproject.toml").unwrap(); + assert!(state.ecosystem_registry.get_for_uri(&pypi_uri).is_some()); + } + + #[tokio::test] + async fn test_document_parsing() { + let state = Arc::new(ServerState::new()); + let uri = + tower_lsp_server::ls_types::Uri::from_file_path("/test/pyproject.toml").unwrap(); + let content = r#"[project] +dependencies = ["requests>=2.0.0"] "#; - fs::write(&cargo_toml_path, content).unwrap(); - let uri = Uri::from_file_path(&cargo_toml_path).unwrap(); + let ecosystem = state + .ecosystem_registry + .get_for_uri(&uri) + .expect("pypi ecosystem not found"); - // Test that load_document_from_disk succeeds - let loaded_content = load_document_from_disk(&uri).await.unwrap(); - assert_eq!(loaded_content, content); + let parse_result = ecosystem.parse_manifest(content, &uri).await; + assert!(parse_result.is_ok()); - // Test that parsing succeeds - let state = Arc::new(ServerState::new()); - let ecosystem = state - .ecosystem_registry - .get_for_uri(&uri) - .expect("Cargo ecosystem"); - let parse_result = ecosystem.parse_manifest(&loaded_content, &uri).await; - assert!(parse_result.is_ok(), "Should parse successfully"); - - // These successful operations are the building blocks of ensure_document_loaded + let doc_state = DocumentState::new_from_parse_result( + "pypi", + content.to_string(), + parse_result.unwrap(), + ); + state.update_document(uri.clone(), doc_state); + + let doc = state.get_document(&uri).unwrap(); + assert_eq!(doc.ecosystem_id, "pypi"); + } } - #[tokio::test] - async fn test_ensure_document_loaded_idempotent_check() { - // Test that repeated loads are idempotent at the state level - let state = Arc::new(ServerState::new()); - let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); - let content = r#"[dependencies] -serde = "1.0""#; + // Go-specific tests + #[cfg(feature = "go")] + mod go_tests { + use super::*; - let ecosystem = state - .ecosystem_registry - .get_for_uri(&uri) - .expect("Cargo ecosystem"); - - // Parse twice to simulate idempotent loads - let parse_result1 = ecosystem.parse_manifest(content, &uri).await.unwrap(); - let parse_result2 = ecosystem.parse_manifest(content, &uri).await.unwrap(); - - // First update - let doc_state1 = - DocumentState::new_from_parse_result("cargo", content.to_string(), parse_result1); - state.update_document(uri.clone(), doc_state1); - assert_eq!(state.document_count(), 1); - - // Second update (idempotent) - let doc_state2 = - DocumentState::new_from_parse_result("cargo", content.to_string(), parse_result2); - state.update_document(uri.clone(), doc_state2); - assert_eq!( - state.document_count(), - 1, - "Should still have only 1 document" - ); + #[test] + fn test_ecosystem_registry_lookup() { + let state = ServerState::new(); + let go_uri = tower_lsp_server::ls_types::Uri::from_file_path("/test/go.mod").unwrap(); + assert!(state.ecosystem_registry.get_for_uri(&go_uri).is_some()); + } + + #[tokio::test] + async fn test_document_parsing() { + let state = Arc::new(ServerState::new()); + let uri = tower_lsp_server::ls_types::Uri::from_file_path("/test/go.mod").unwrap(); + let content = r#"module example.com/mymodule + +go 1.21 + +require github.com/gorilla/mux v1.8.0 +"#; + + let ecosystem = state + .ecosystem_registry + .get_for_uri(&uri) + .expect("go ecosystem not found"); + + let parse_result = ecosystem.parse_manifest(content, &uri).await; + assert!(parse_result.is_ok()); + + let doc_state = DocumentState::new_from_parse_result( + "go", + content.to_string(), + parse_result.unwrap(), + ); + state.update_document(uri.clone(), doc_state); + + let doc = state.get_document(&uri).unwrap(); + assert_eq!(doc.ecosystem_id, "go"); + } } } diff --git a/crates/deps-lsp/src/document/state.rs b/crates/deps-lsp/src/document/state.rs index adc4bacd..9cec642a 100644 --- a/crates/deps-lsp/src/document/state.rs +++ b/crates/deps-lsp/src/document/state.rs @@ -1,12 +1,17 @@ use dashmap::DashMap; -use deps_cargo::{CargoVersion, ParsedDependency}; use deps_core::HttpCache; use deps_core::lockfile::LockFileCache; use deps_core::{EcosystemRegistry, ParseResult}; +use std::collections::HashMap; + +#[cfg(feature = "cargo")] +use deps_cargo::{CargoVersion, ParsedDependency}; +#[cfg(feature = "go")] use deps_go::{GoDependency, GoVersion}; +#[cfg(feature = "npm")] use deps_npm::{NpmDependency, NpmVersion}; +#[cfg(feature = "pypi")] use deps_pypi::{PypiDependency, PypiVersion}; -use std::collections::HashMap; use std::sync::Arc; use std::time::{Duration, Instant}; use tokio::task::JoinHandle; @@ -19,64 +24,98 @@ use tower_lsp_server::ls_types::Uri; #[derive(Debug, Clone)] #[non_exhaustive] pub enum UnifiedDependency { + #[cfg(feature = "cargo")] Cargo(ParsedDependency), + #[cfg(feature = "npm")] Npm(NpmDependency), + #[cfg(feature = "pypi")] Pypi(PypiDependency), + #[cfg(feature = "go")] Go(GoDependency), } impl UnifiedDependency { /// Returns the dependency name. + #[allow(unreachable_patterns)] pub fn name(&self) -> &str { match self { + #[cfg(feature = "cargo")] UnifiedDependency::Cargo(dep) => &dep.name, + #[cfg(feature = "npm")] UnifiedDependency::Npm(dep) => &dep.name, + #[cfg(feature = "pypi")] UnifiedDependency::Pypi(dep) => &dep.name, + #[cfg(feature = "go")] UnifiedDependency::Go(dep) => &dep.module_path, + _ => unreachable!("no ecosystem features enabled"), } } /// Returns the name range for LSP operations. + #[allow(unreachable_patterns)] pub fn name_range(&self) -> tower_lsp_server::ls_types::Range { match self { + #[cfg(feature = "cargo")] UnifiedDependency::Cargo(dep) => dep.name_range, + #[cfg(feature = "npm")] UnifiedDependency::Npm(dep) => dep.name_range, + #[cfg(feature = "pypi")] UnifiedDependency::Pypi(dep) => dep.name_range, + #[cfg(feature = "go")] UnifiedDependency::Go(dep) => dep.module_path_range, + _ => unreachable!("no ecosystem features enabled"), } } /// Returns the version requirement string if present. + #[allow(unreachable_patterns)] pub fn version_req(&self) -> Option<&str> { match self { + #[cfg(feature = "cargo")] UnifiedDependency::Cargo(dep) => dep.version_req.as_deref(), + #[cfg(feature = "npm")] UnifiedDependency::Npm(dep) => dep.version_req.as_deref(), + #[cfg(feature = "pypi")] UnifiedDependency::Pypi(dep) => dep.version_req.as_deref(), + #[cfg(feature = "go")] UnifiedDependency::Go(dep) => dep.version.as_deref(), + _ => unreachable!("no ecosystem features enabled"), } } /// Returns the version range for LSP operations if present. + #[allow(unreachable_patterns)] pub fn version_range(&self) -> Option { match self { + #[cfg(feature = "cargo")] UnifiedDependency::Cargo(dep) => dep.version_range, + #[cfg(feature = "npm")] UnifiedDependency::Npm(dep) => dep.version_range, + #[cfg(feature = "pypi")] UnifiedDependency::Pypi(dep) => dep.version_range, + #[cfg(feature = "go")] UnifiedDependency::Go(dep) => dep.version_range, + _ => unreachable!("no ecosystem features enabled"), } } /// Returns true if this is a registry dependency (not Git/Path). + #[allow(unreachable_patterns)] pub fn is_registry(&self) -> bool { match self { + #[cfg(feature = "cargo")] UnifiedDependency::Cargo(dep) => { matches!(dep.source, deps_cargo::DependencySource::Registry) } + #[cfg(feature = "npm")] UnifiedDependency::Npm(_) => true, + #[cfg(feature = "pypi")] UnifiedDependency::Pypi(dep) => { matches!(dep.source, deps_pypi::PypiDependencySource::PyPI) } + #[cfg(feature = "go")] UnifiedDependency::Go(_) => true, + _ => unreachable!("no ecosystem features enabled"), } } } @@ -87,30 +126,46 @@ impl UnifiedDependency { #[derive(Debug, Clone)] #[non_exhaustive] pub enum UnifiedVersion { + #[cfg(feature = "cargo")] Cargo(CargoVersion), + #[cfg(feature = "npm")] Npm(NpmVersion), + #[cfg(feature = "pypi")] Pypi(PypiVersion), + #[cfg(feature = "go")] Go(GoVersion), } impl UnifiedVersion { /// Returns the version number as a string. + #[allow(unreachable_patterns)] pub fn version_string(&self) -> &str { match self { + #[cfg(feature = "cargo")] UnifiedVersion::Cargo(v) => &v.num, + #[cfg(feature = "npm")] UnifiedVersion::Npm(v) => &v.version, + #[cfg(feature = "pypi")] UnifiedVersion::Pypi(v) => &v.version, + #[cfg(feature = "go")] UnifiedVersion::Go(v) => &v.version, + _ => unreachable!("no ecosystem features enabled"), } } /// Returns true if this version is yanked/deprecated. + #[allow(unreachable_patterns)] pub fn is_yanked(&self) -> bool { match self { + #[cfg(feature = "cargo")] UnifiedVersion::Cargo(v) => v.yanked, + #[cfg(feature = "npm")] UnifiedVersion::Npm(v) => v.deprecated, + #[cfg(feature = "pypi")] UnifiedVersion::Pypi(v) => v.yanked, + #[cfg(feature = "go")] UnifiedVersion::Go(v) => v.retracted, + _ => unreachable!("no ecosystem features enabled"), } } } @@ -506,21 +561,20 @@ impl ServerState { let lockfile_cache = Arc::new(LockFileCache::new()); let ecosystem_registry = Arc::new(EcosystemRegistry::new()); - // Register Cargo ecosystem - let cargo_ecosystem = Arc::new(deps_cargo::CargoEcosystem::new(Arc::clone(&cache))); - ecosystem_registry.register(cargo_ecosystem); + // Register ecosystems based on enabled features + #[cfg(feature = "cargo")] + ecosystem_registry.register(Arc::new(deps_cargo::CargoEcosystem::new(Arc::clone( + &cache, + )))); - // Register npm ecosystem - let npm_ecosystem = Arc::new(deps_npm::NpmEcosystem::new(Arc::clone(&cache))); - ecosystem_registry.register(npm_ecosystem); + #[cfg(feature = "npm")] + ecosystem_registry.register(Arc::new(deps_npm::NpmEcosystem::new(Arc::clone(&cache)))); - // Register PyPI ecosystem - let pypi_ecosystem = Arc::new(deps_pypi::PypiEcosystem::new(Arc::clone(&cache))); - ecosystem_registry.register(pypi_ecosystem); + #[cfg(feature = "pypi")] + ecosystem_registry.register(Arc::new(deps_pypi::PypiEcosystem::new(Arc::clone(&cache)))); - // Register Go ecosystem - let go_ecosystem = Arc::new(deps_go::GoEcosystem::new(Arc::clone(&cache))); - ecosystem_registry.register(go_ecosystem); + #[cfg(feature = "go")] + ecosystem_registry.register(Arc::new(deps_go::GoEcosystem::new(Arc::clone(&cache)))); // Create cold start limiter with default 100ms interval (10 req/sec per URI) let cold_start_limiter = ColdStartLimiter::new(Duration::from_millis(100)); @@ -639,84 +693,65 @@ impl Default for ServerState { #[cfg(test)] mod tests { use super::*; - use deps_cargo::{DependencySection, DependencySource}; - use tower_lsp_server::ls_types::{Position, Range}; - - fn create_test_cargo_dependency() -> UnifiedDependency { - UnifiedDependency::Cargo(ParsedDependency { - name: "serde".into(), - name_range: Range::new(Position::new(0, 0), Position::new(0, 5)), - version_req: Some("1.0".into()), - version_range: Some(Range::new(Position::new(0, 9), Position::new(0, 14))), - features: vec![], - features_range: None, - source: DependencySource::Registry, - workspace_inherited: false, - section: DependencySection::Dependencies, - }) - } + + // ========================================================================= + // Generic tests (no feature flag required) + // ========================================================================= #[test] fn test_ecosystem_from_filename() { + #[cfg(feature = "cargo")] assert_eq!( Ecosystem::from_filename("Cargo.toml"), Some(Ecosystem::Cargo) ); + #[cfg(feature = "npm")] assert_eq!( Ecosystem::from_filename("package.json"), Some(Ecosystem::Npm) ); + #[cfg(feature = "pypi")] assert_eq!( Ecosystem::from_filename("pyproject.toml"), Some(Ecosystem::Pypi) ); + #[cfg(feature = "go")] + assert_eq!(Ecosystem::from_filename("go.mod"), Some(Ecosystem::Go)); assert_eq!(Ecosystem::from_filename("unknown.txt"), None); } #[test] fn test_ecosystem_from_uri() { - let cargo_uri = Uri::from_file_path("/path/to/Cargo.toml").unwrap(); - assert_eq!(Ecosystem::from_uri(&cargo_uri), Some(Ecosystem::Cargo)); - - let npm_uri = Uri::from_file_path("/path/to/package.json").unwrap(); - assert_eq!(Ecosystem::from_uri(&npm_uri), Some(Ecosystem::Npm)); - - let pypi_uri = Uri::from_file_path("/path/to/pyproject.toml").unwrap(); - assert_eq!(Ecosystem::from_uri(&pypi_uri), Some(Ecosystem::Pypi)); - + #[cfg(feature = "cargo")] + { + let cargo_uri = Uri::from_file_path("/path/to/Cargo.toml").unwrap(); + assert_eq!(Ecosystem::from_uri(&cargo_uri), Some(Ecosystem::Cargo)); + } + #[cfg(feature = "npm")] + { + let npm_uri = Uri::from_file_path("/path/to/package.json").unwrap(); + assert_eq!(Ecosystem::from_uri(&npm_uri), Some(Ecosystem::Npm)); + } + #[cfg(feature = "pypi")] + { + let pypi_uri = Uri::from_file_path("/path/to/pyproject.toml").unwrap(); + assert_eq!(Ecosystem::from_uri(&pypi_uri), Some(Ecosystem::Pypi)); + } + #[cfg(feature = "go")] + { + let go_uri = Uri::from_file_path("/path/to/go.mod").unwrap(); + assert_eq!(Ecosystem::from_uri(&go_uri), Some(Ecosystem::Go)); + } let unknown_uri = Uri::from_file_path("/path/to/README.md").unwrap(); assert_eq!(Ecosystem::from_uri(&unknown_uri), None); } #[test] - fn test_document_state_creation() { - let deps = vec![create_test_cargo_dependency()]; - let state = DocumentState::new(Ecosystem::Cargo, "test content".into(), deps); - - assert_eq!(state.ecosystem, Ecosystem::Cargo); - assert_eq!(state.content, "test content"); - assert_eq!(state.dependencies.len(), 1); - assert!(state.versions.is_empty()); - } - - #[test] - fn test_document_state_update_versions() { - let deps = vec![create_test_cargo_dependency()]; - let mut state = DocumentState::new(Ecosystem::Cargo, "test".into(), deps); - - let mut versions = HashMap::new(); - versions.insert( - "serde".into(), - UnifiedVersion::Cargo(CargoVersion { - num: "1.0.0".into(), - yanked: false, - features: HashMap::new(), - }), - ); - - state.update_versions(versions); - assert_eq!(state.versions.len(), 1); - assert!(state.versions.contains_key("serde")); + fn test_ecosystem_from_filename_edge_cases() { + assert_eq!(Ecosystem::from_filename(""), None); + assert_eq!(Ecosystem::from_filename("cargo.toml"), None); + assert_eq!(Ecosystem::from_filename("CARGO.TOML"), None); + assert_eq!(Ecosystem::from_filename("requirements.txt"), None); } #[test] @@ -727,24 +762,8 @@ mod tests { } #[test] - fn test_server_state_document_operations() { - let state = ServerState::new(); - let uri = Uri::from_file_path("/test.toml").unwrap(); - let deps = vec![create_test_cargo_dependency()]; - let doc_state = DocumentState::new(Ecosystem::Cargo, "test".into(), deps); - - // Insert document - state.update_document(uri.clone(), doc_state.clone()); - assert_eq!(state.document_count(), 1); - - // Get document - let retrieved = state.get_document(&uri); - assert!(retrieved.is_some()); - assert_eq!(retrieved.unwrap().content, "test"); - - // Remove document - let removed = state.remove_document(&uri); - assert!(removed.is_some()); + fn test_server_state_default() { + let state = ServerState::default(); assert_eq!(state.document_count(), 0); } @@ -753,191 +772,14 @@ mod tests { let state = ServerState::new(); let uri = Uri::from_file_path("/test.toml").unwrap(); - // Spawn task let task = tokio::spawn(async { tokio::time::sleep(std::time::Duration::from_millis(100)).await; }); state.spawn_background_task(uri.clone(), task).await; - - // Cancel task state.cancel_background_task(&uri).await; } - #[test] - fn test_unified_dependency_name() { - use deps_cargo::{DependencySection, DependencySource}; - use tower_lsp_server::ls_types::{Position, Range}; - - let cargo_dep = UnifiedDependency::Cargo(ParsedDependency { - name: "serde".into(), - name_range: Range::new(Position::new(0, 0), Position::new(0, 5)), - version_req: Some("1.0".into()), - version_range: Some(Range::new(Position::new(0, 9), Position::new(0, 14))), - features: vec![], - features_range: None, - source: DependencySource::Registry, - workspace_inherited: false, - section: DependencySection::Dependencies, - }); - - assert_eq!(cargo_dep.name(), "serde"); - assert_eq!(cargo_dep.version_req(), Some("1.0")); - assert!(cargo_dep.is_registry()); - } - - #[test] - fn test_unified_dependency_npm() { - use deps_npm::{NpmDependency, NpmDependencySection}; - use tower_lsp_server::ls_types::{Position, Range}; - - let npm_dep = UnifiedDependency::Npm(NpmDependency { - name: "express".into(), - name_range: Range::new(Position::new(0, 0), Position::new(0, 7)), - version_req: Some("^4.0.0".into()), - version_range: Some(Range::new(Position::new(0, 11), Position::new(0, 18))), - section: NpmDependencySection::Dependencies, - }); - - assert_eq!(npm_dep.name(), "express"); - assert_eq!(npm_dep.version_req(), Some("^4.0.0")); - assert!(npm_dep.is_registry()); - } - - #[test] - fn test_unified_dependency_pypi() { - use deps_pypi::{PypiDependency, PypiDependencySection, PypiDependencySource}; - use tower_lsp_server::ls_types::{Position, Range}; - - let pypi_dep = UnifiedDependency::Pypi(PypiDependency { - name: "requests".into(), - name_range: Range::new(Position::new(0, 0), Position::new(0, 8)), - version_req: Some(">=2.0.0".into()), - version_range: Some(Range::new(Position::new(0, 10), Position::new(0, 18))), - extras: vec![], - extras_range: None, - markers: None, - markers_range: None, - source: PypiDependencySource::PyPI, - section: PypiDependencySection::Dependencies, - }); - - assert_eq!(pypi_dep.name(), "requests"); - assert_eq!(pypi_dep.version_req(), Some(">=2.0.0")); - assert!(pypi_dep.is_registry()); - } - - #[test] - fn test_unified_version_cargo() { - let version = UnifiedVersion::Cargo(CargoVersion { - num: "1.0.0".into(), - yanked: false, - features: HashMap::new(), - }); - - assert_eq!(version.version_string(), "1.0.0"); - assert!(!version.is_yanked()); - } - - #[test] - fn test_unified_version_npm() { - let version = UnifiedVersion::Npm(deps_npm::NpmVersion { - version: "4.18.2".into(), - deprecated: false, - }); - - assert_eq!(version.version_string(), "4.18.2"); - assert!(!version.is_yanked()); - } - - #[test] - fn test_unified_version_pypi() { - let version = UnifiedVersion::Pypi(deps_pypi::PypiVersion { - version: "2.31.0".into(), - yanked: true, - }); - - assert_eq!(version.version_string(), "2.31.0"); - assert!(version.is_yanked()); - } - - #[test] - fn test_document_state_new_from_parse_result() { - let state = ServerState::new(); - let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); - let ecosystem = state.ecosystem_registry.get("cargo").unwrap(); - - let content = r#"[dependencies] -serde = "1.0" -"# - .to_string(); - - let parse_result = tokio::runtime::Runtime::new() - .unwrap() - .block_on(ecosystem.parse_manifest(&content, &uri)) - .unwrap(); - - let doc_state = - DocumentState::new_from_parse_result("cargo", content.clone(), parse_result); - - assert_eq!(doc_state.ecosystem_id, "cargo"); - assert_eq!(doc_state.content, content); - assert!(doc_state.parse_result.is_some()); - assert!(doc_state.versions.is_empty()); - assert!(doc_state.cached_versions.is_empty()); - } - - #[test] - fn test_document_state_update_resolved_versions() { - let deps = vec![create_test_cargo_dependency()]; - let mut state = DocumentState::new(Ecosystem::Cargo, "test".into(), deps); - - let mut resolved = HashMap::new(); - resolved.insert("serde".into(), "1.0.195".into()); - - state.update_resolved_versions(resolved); - assert_eq!(state.resolved_versions.len(), 1); - assert_eq!( - state.resolved_versions.get("serde"), - Some(&"1.0.195".into()) - ); - } - - #[test] - fn test_document_state_update_cached_versions() { - let deps = vec![create_test_cargo_dependency()]; - let mut state = DocumentState::new(Ecosystem::Cargo, "test".into(), deps); - - let mut cached = HashMap::new(); - cached.insert("serde".into(), "1.0.210".into()); - - state.update_cached_versions(cached); - assert_eq!(state.cached_versions.len(), 1); - assert_eq!(state.cached_versions.get("serde"), Some(&"1.0.210".into())); - } - - #[test] - fn test_document_state_parse_result_accessor() { - let deps = vec![create_test_cargo_dependency()]; - let state = DocumentState::new(Ecosystem::Cargo, "test".into(), deps); - - assert!(state.parse_result().is_none()); - } - - #[test] - fn test_ecosystem_from_filename_edge_cases() { - assert_eq!(Ecosystem::from_filename(""), None); - assert_eq!(Ecosystem::from_filename("cargo.toml"), None); - assert_eq!(Ecosystem::from_filename("CARGO.TOML"), None); - assert_eq!(Ecosystem::from_filename("requirements.txt"), None); - } - - #[test] - fn test_server_state_default() { - let state = ServerState::default(); - assert_eq!(state.document_count(), 0); - } - #[tokio::test] async fn test_spawn_background_task_cancels_previous() { let state = ServerState::new(); @@ -946,15 +788,12 @@ serde = "1.0" let task1 = tokio::spawn(async { tokio::time::sleep(std::time::Duration::from_secs(10)).await; }); - state.spawn_background_task(uri.clone(), task1).await; let task2 = tokio::spawn(async { tokio::time::sleep(std::time::Duration::from_millis(10)).await; }); - state.spawn_background_task(uri.clone(), task2).await; - state.cancel_background_task(&uri).await; } @@ -962,467 +801,570 @@ serde = "1.0" async fn test_cancel_background_task_nonexistent() { let state = ServerState::new(); let uri = Uri::from_file_path("/test.toml").unwrap(); - state.cancel_background_task(&uri).await; } - #[test] - fn test_document_state_clone() { - let deps = vec![create_test_cargo_dependency()]; - let state = DocumentState::new(Ecosystem::Cargo, "test content".into(), deps); + // ========================================================================= + // ColdStartLimiter tests + // ========================================================================= - let cloned = state.clone(); + mod cold_start_limiter { + use super::*; + use std::time::Duration; - assert_eq!(cloned.ecosystem, state.ecosystem); - assert_eq!(cloned.content, state.content); - assert_eq!(cloned.dependencies.len(), state.dependencies.len()); - assert!(cloned.parse_result.is_none()); - } + #[test] + fn test_allows_first_request() { + let limiter = ColdStartLimiter::new(Duration::from_millis(100)); + let uri = Uri::from_file_path("/test.toml").unwrap(); + assert!( + limiter.allow_cold_start(&uri), + "First request should be allowed" + ); + } - #[test] - fn test_document_state_debug() { - let deps = vec![create_test_cargo_dependency()]; - let state = DocumentState::new(Ecosystem::Cargo, "test".into(), deps); + #[test] + fn test_blocks_rapid_requests() { + let limiter = ColdStartLimiter::new(Duration::from_millis(100)); + let uri = Uri::from_file_path("/test.toml").unwrap(); - let debug_str = format!("{:?}", state); - assert!(debug_str.contains("DocumentState")); - assert!(debug_str.contains("ecosystem")); - } + assert!(limiter.allow_cold_start(&uri), "First request allowed"); + assert!( + !limiter.allow_cold_start(&uri), + "Second immediate request should be blocked" + ); + } - #[test] - fn test_unified_dependency_git_source() { - use deps_cargo::{DependencySection, DependencySource}; - use tower_lsp_server::ls_types::{Position, Range}; + #[tokio::test] + async fn test_allows_after_interval() { + let limiter = ColdStartLimiter::new(Duration::from_millis(50)); + let uri = Uri::from_file_path("/test.toml").unwrap(); + + assert!(limiter.allow_cold_start(&uri), "First request allowed"); + tokio::time::sleep(Duration::from_millis(60)).await; + assert!( + limiter.allow_cold_start(&uri), + "Request after interval should be allowed" + ); + } - let git_dep = UnifiedDependency::Cargo(ParsedDependency { - name: "custom".into(), - name_range: Range::new(Position::new(0, 0), Position::new(0, 6)), - version_req: None, - version_range: None, - features: vec![], - features_range: None, - source: DependencySource::Git { - url: "https://github.com/user/repo".into(), - rev: None, - }, - workspace_inherited: false, - section: DependencySection::Dependencies, - }); + #[test] + fn test_different_uris_independent() { + let limiter = ColdStartLimiter::new(Duration::from_millis(100)); + let uri1 = Uri::from_file_path("/test1.toml").unwrap(); + let uri2 = Uri::from_file_path("/test2.toml").unwrap(); + + assert!(limiter.allow_cold_start(&uri1), "URI 1 first request"); + assert!(limiter.allow_cold_start(&uri2), "URI 2 first request"); + assert!( + !limiter.allow_cold_start(&uri1), + "URI 1 second request blocked" + ); + assert!( + !limiter.allow_cold_start(&uri2), + "URI 2 second request blocked" + ); + } - assert!(!git_dep.is_registry()); - } + #[test] + fn test_cleanup() { + let limiter = ColdStartLimiter::new(Duration::from_millis(100)); + let uri1 = Uri::from_file_path("/test1.toml").unwrap(); + let uri2 = Uri::from_file_path("/test2.toml").unwrap(); + + limiter.allow_cold_start(&uri1); + limiter.allow_cold_start(&uri2); + assert_eq!(limiter.tracked_count(), 2, "Should track 2 URIs"); + + limiter.cleanup_old_entries(Duration::from_millis(0)); + assert_eq!( + limiter.tracked_count(), + 0, + "All entries should be cleaned up" + ); + } - #[test] - fn test_document_state_new_without_parse_result() { - let content = r#"[dependencies] -serde = "1.0" -"# - .to_string(); - - let doc_state = DocumentState::new_without_parse_result("cargo", content.clone()); - - assert_eq!(doc_state.ecosystem_id, "cargo"); - assert_eq!(doc_state.ecosystem, Ecosystem::Cargo); - assert_eq!(doc_state.content, content); - assert!(doc_state.parse_result.is_none()); - assert!(doc_state.dependencies.is_empty()); - assert!(doc_state.versions.is_empty()); - assert!(doc_state.cached_versions.is_empty()); - assert!(doc_state.resolved_versions.is_empty()); - } + #[tokio::test] + async fn test_concurrent_access() { + use std::sync::Arc; - #[test] - fn test_document_state_new_without_parse_result_npm() { - let content = r#"{"dependencies": {"express": "^4.18.0"}}"#.to_string(); + let limiter = Arc::new(ColdStartLimiter::new(Duration::from_millis(100))); + let uri = Uri::from_file_path("/concurrent-test.toml").unwrap(); - let doc_state = DocumentState::new_without_parse_result("npm", content.clone()); + let mut handles = vec![]; + const CONCURRENT_TASKS: usize = 10; - assert_eq!(doc_state.ecosystem_id, "npm"); - assert_eq!(doc_state.ecosystem, Ecosystem::Npm); - assert!(doc_state.parse_result.is_none()); - } + for _ in 0..CONCURRENT_TASKS { + let limiter_clone = Arc::clone(&limiter); + let uri_clone = uri.clone(); + let handle = + tokio::spawn(async move { limiter_clone.allow_cold_start(&uri_clone) }); + handles.push(handle); + } - #[test] - fn test_document_state_new_without_parse_result_pypi() { - let content = r#"[project] -dependencies = ["requests>=2.0.0"] -"# - .to_string(); + let mut results = vec![]; + for handle in handles { + results.push(handle.await.unwrap()); + } - let doc_state = DocumentState::new_without_parse_result("pypi", content.clone()); + let allowed_count = results.iter().filter(|&&allowed| allowed).count(); + assert_eq!(allowed_count, 1, "Exactly one concurrent request allowed"); - assert_eq!(doc_state.ecosystem_id, "pypi"); - assert_eq!(doc_state.ecosystem, Ecosystem::Pypi); - assert!(doc_state.parse_result.is_none()); + let blocked_count = results.iter().filter(|&&allowed| !allowed).count(); + assert_eq!( + blocked_count, + CONCURRENT_TASKS - 1, + "Rest should be blocked" + ); + } } - #[test] - fn test_cold_start_limiter_allows_first_request() { - use std::time::Duration; - - let limiter = ColdStartLimiter::new(Duration::from_millis(100)); - let uri = Uri::from_file_path("/test.toml").unwrap(); + // ========================================================================= + // Cargo ecosystem tests + // ========================================================================= - assert!( - limiter.allow_cold_start(&uri), - "First request should be allowed" - ); - } + #[cfg(feature = "cargo")] + mod cargo_tests { + use super::*; + use deps_cargo::{DependencySection, DependencySource}; + use tower_lsp_server::ls_types::{Position, Range}; - #[test] - fn test_cold_start_limiter_blocks_rapid_requests() { - use std::time::Duration; + fn create_test_dependency() -> UnifiedDependency { + UnifiedDependency::Cargo(ParsedDependency { + name: "serde".into(), + name_range: Range::new(Position::new(0, 0), Position::new(0, 5)), + version_req: Some("1.0".into()), + version_range: Some(Range::new(Position::new(0, 9), Position::new(0, 14))), + features: vec![], + features_range: None, + source: DependencySource::Registry, + workspace_inherited: false, + section: DependencySection::Dependencies, + }) + } - let limiter = ColdStartLimiter::new(Duration::from_millis(100)); - let uri = Uri::from_file_path("/test.toml").unwrap(); + #[test] + fn test_document_state_creation() { + let deps = vec![create_test_dependency()]; + let state = DocumentState::new(Ecosystem::Cargo, "test content".into(), deps); - assert!(limiter.allow_cold_start(&uri), "First request allowed"); - assert!( - !limiter.allow_cold_start(&uri), - "Second immediate request should be blocked" - ); - } + assert_eq!(state.ecosystem, Ecosystem::Cargo); + assert_eq!(state.content, "test content"); + assert_eq!(state.dependencies.len(), 1); + assert!(state.versions.is_empty()); + } - #[tokio::test] - async fn test_cold_start_limiter_allows_after_interval() { - use std::time::Duration; + #[test] + fn test_document_state_update_versions() { + let deps = vec![create_test_dependency()]; + let mut state = DocumentState::new(Ecosystem::Cargo, "test".into(), deps); + + let mut versions = HashMap::new(); + versions.insert( + "serde".into(), + UnifiedVersion::Cargo(CargoVersion { + num: "1.0.0".into(), + yanked: false, + features: HashMap::new(), + }), + ); + + state.update_versions(versions); + assert_eq!(state.versions.len(), 1); + assert!(state.versions.contains_key("serde")); + } - let limiter = ColdStartLimiter::new(Duration::from_millis(50)); - let uri = Uri::from_file_path("/test.toml").unwrap(); + #[test] + fn test_server_state_document_operations() { + let state = ServerState::new(); + let uri = Uri::from_file_path("/test.toml").unwrap(); + let deps = vec![create_test_dependency()]; + let doc_state = DocumentState::new(Ecosystem::Cargo, "test".into(), deps); - assert!(limiter.allow_cold_start(&uri), "First request allowed"); + state.update_document(uri.clone(), doc_state.clone()); + assert_eq!(state.document_count(), 1); - // Wait for interval to expire - tokio::time::sleep(Duration::from_millis(60)).await; + let retrieved = state.get_document(&uri); + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().content, "test"); - assert!( - limiter.allow_cold_start(&uri), - "Request after interval should be allowed" - ); - } + let removed = state.remove_document(&uri); + assert!(removed.is_some()); + assert_eq!(state.document_count(), 0); + } - #[test] - fn test_cold_start_limiter_different_uris_independent() { - use std::time::Duration; + #[test] + fn test_unified_dependency_name() { + let cargo_dep = create_test_dependency(); + assert_eq!(cargo_dep.name(), "serde"); + assert_eq!(cargo_dep.version_req(), Some("1.0")); + assert!(cargo_dep.is_registry()); + } - let limiter = ColdStartLimiter::new(Duration::from_millis(100)); - let uri1 = Uri::from_file_path("/test1.toml").unwrap(); - let uri2 = Uri::from_file_path("/test2.toml").unwrap(); + #[test] + fn test_unified_dependency_git_source() { + let git_dep = UnifiedDependency::Cargo(ParsedDependency { + name: "custom".into(), + name_range: Range::new(Position::new(0, 0), Position::new(0, 6)), + version_req: None, + version_range: None, + features: vec![], + features_range: None, + source: DependencySource::Git { + url: "https://github.com/user/repo".into(), + rev: None, + }, + workspace_inherited: false, + section: DependencySection::Dependencies, + }); + assert!(!git_dep.is_registry()); + } - assert!(limiter.allow_cold_start(&uri1), "URI 1 first request"); - assert!(limiter.allow_cold_start(&uri2), "URI 2 first request"); - assert!( - !limiter.allow_cold_start(&uri1), - "URI 1 second request blocked" - ); - assert!( - !limiter.allow_cold_start(&uri2), - "URI 2 second request blocked" - ); - } + #[test] + fn test_unified_version() { + let version = UnifiedVersion::Cargo(CargoVersion { + num: "1.0.0".into(), + yanked: false, + features: HashMap::new(), + }); + assert_eq!(version.version_string(), "1.0.0"); + assert!(!version.is_yanked()); + } - #[test] - fn test_cold_start_limiter_cleanup() { - use std::time::Duration; + #[test] + fn test_document_state_new_from_parse_result() { + let state = ServerState::new(); + let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); + let ecosystem = state.ecosystem_registry.get("cargo").unwrap(); + let content = "[dependencies]\nserde = \"1.0\"\n".to_string(); - let limiter = ColdStartLimiter::new(Duration::from_millis(100)); - let uri1 = Uri::from_file_path("/test1.toml").unwrap(); - let uri2 = Uri::from_file_path("/test2.toml").unwrap(); + let parse_result = tokio::runtime::Runtime::new() + .unwrap() + .block_on(ecosystem.parse_manifest(&content, &uri)) + .unwrap(); - limiter.allow_cold_start(&uri1); - limiter.allow_cold_start(&uri2); + let doc_state = + DocumentState::new_from_parse_result("cargo", content.clone(), parse_result); - assert_eq!(limiter.tracked_count(), 2, "Should track 2 URIs"); + assert_eq!(doc_state.ecosystem_id, "cargo"); + assert_eq!(doc_state.content, content); + assert!(doc_state.parse_result.is_some()); + } - // Cleanup entries older than 0ms (all entries) - limiter.cleanup_old_entries(Duration::from_millis(0)); + #[test] + fn test_document_state_new_without_parse_result() { + let content = "[dependencies]\nserde = \"1.0\"\n".to_string(); + let doc_state = DocumentState::new_without_parse_result("cargo", content.clone()); - assert_eq!( - limiter.tracked_count(), - 0, - "All entries should be cleaned up" - ); - } + assert_eq!(doc_state.ecosystem_id, "cargo"); + assert_eq!(doc_state.ecosystem, Ecosystem::Cargo); + assert!(doc_state.parse_result.is_none()); + assert!(doc_state.dependencies.is_empty()); + } - #[tokio::test] - async fn test_cold_start_limiter_concurrent_access() { - use std::sync::Arc; - use std::time::Duration; + #[test] + fn test_document_state_update_resolved_versions() { + let deps = vec![create_test_dependency()]; + let mut state = DocumentState::new(Ecosystem::Cargo, "test".into(), deps); - let limiter = Arc::new(ColdStartLimiter::new(Duration::from_millis(100))); - let uri = Uri::from_file_path("/concurrent-test.toml").unwrap(); + let mut resolved = HashMap::new(); + resolved.insert("serde".into(), "1.0.195".into()); - // Spawn multiple concurrent tasks trying to cold start the same URI - let mut handles = vec![]; - const CONCURRENT_TASKS: usize = 10; + state.update_resolved_versions(resolved); + assert_eq!(state.resolved_versions.len(), 1); + assert_eq!( + state.resolved_versions.get("serde"), + Some(&"1.0.195".into()) + ); + } - for _ in 0..CONCURRENT_TASKS { - let limiter_clone = Arc::clone(&limiter); - let uri_clone = uri.clone(); + #[test] + fn test_document_state_update_cached_versions() { + let deps = vec![create_test_dependency()]; + let mut state = DocumentState::new(Ecosystem::Cargo, "test".into(), deps); - let handle = tokio::spawn(async move { limiter_clone.allow_cold_start(&uri_clone) }); + let mut cached = HashMap::new(); + cached.insert("serde".into(), "1.0.210".into()); - handles.push(handle); + state.update_cached_versions(cached); + assert_eq!(state.cached_versions.len(), 1); } - // Collect results - let mut results = vec![]; - for handle in handles { - let allowed = handle.await.unwrap(); - results.push(allowed); + #[test] + fn test_document_state_parse_result_accessor() { + let deps = vec![create_test_dependency()]; + let state = DocumentState::new(Ecosystem::Cargo, "test".into(), deps); + assert!(state.parse_result().is_none()); } - // Exactly one request should be allowed (first one wins) - let allowed_count = results.iter().filter(|&&allowed| allowed).count(); - assert_eq!( - allowed_count, 1, - "Exactly one concurrent request should be allowed, got {}", - allowed_count - ); + #[test] + fn test_document_state_clone() { + let deps = vec![create_test_dependency()]; + let state = DocumentState::new(Ecosystem::Cargo, "test content".into(), deps); + let cloned = state.clone(); - // The rest should be rate limited - let blocked_count = results.iter().filter(|&&allowed| !allowed).count(); - assert_eq!( - blocked_count, - CONCURRENT_TASKS - 1, - "All other requests should be blocked" - ); - } + assert_eq!(cloned.ecosystem, state.ecosystem); + assert_eq!(cloned.content, state.content); + assert_eq!(cloned.dependencies.len(), state.dependencies.len()); + assert!(cloned.parse_result.is_none()); + } - #[test] - fn test_ecosystem_from_filename_go() { - assert_eq!(Ecosystem::from_filename("go.mod"), Some(Ecosystem::Go)); + #[test] + fn test_document_state_debug() { + let deps = vec![create_test_dependency()]; + let state = DocumentState::new(Ecosystem::Cargo, "test".into(), deps); + let debug_str = format!("{:?}", state); + assert!(debug_str.contains("DocumentState")); + } } - #[test] - fn test_ecosystem_from_uri_go() { - let go_uri = Uri::from_file_path("/path/to/go.mod").unwrap(); - assert_eq!(Ecosystem::from_uri(&go_uri), Some(Ecosystem::Go)); - } + // ========================================================================= + // npm ecosystem tests + // ========================================================================= - #[test] - fn test_unified_dependency_go() { - use deps_go::{GoDependency, GoDirective}; + #[cfg(feature = "npm")] + mod npm_tests { + use super::*; + use deps_npm::{NpmDependency, NpmDependencySection}; use tower_lsp_server::ls_types::{Position, Range}; - let go_dep = UnifiedDependency::Go(GoDependency { - module_path: "github.com/gin-gonic/gin".into(), - module_path_range: Range::new(Position::new(0, 0), Position::new(0, 25)), - version: Some("v1.9.1".into()), - version_range: Some(Range::new(Position::new(0, 26), Position::new(0, 32))), - directive: GoDirective::Require, - indirect: false, - }); - - assert_eq!(go_dep.name(), "github.com/gin-gonic/gin"); - assert_eq!(go_dep.version_req(), Some("v1.9.1")); - assert!(go_dep.is_registry()); - } + #[test] + fn test_unified_dependency() { + let npm_dep = UnifiedDependency::Npm(NpmDependency { + name: "express".into(), + name_range: Range::new(Position::new(0, 0), Position::new(0, 7)), + version_req: Some("^4.0.0".into()), + version_range: Some(Range::new(Position::new(0, 11), Position::new(0, 18))), + section: NpmDependencySection::Dependencies, + }); + + assert_eq!(npm_dep.name(), "express"); + assert_eq!(npm_dep.version_req(), Some("^4.0.0")); + assert!(npm_dep.is_registry()); + } - #[test] - fn test_unified_dependency_go_name_range() { - use deps_go::{GoDependency, GoDirective}; - use tower_lsp_server::ls_types::{Position, Range}; + #[test] + fn test_unified_version() { + let version = UnifiedVersion::Npm(deps_npm::NpmVersion { + version: "4.18.2".into(), + deprecated: false, + }); + assert_eq!(version.version_string(), "4.18.2"); + assert!(!version.is_yanked()); + } - let range = Range::new(Position::new(5, 10), Position::new(5, 35)); - let go_dep = UnifiedDependency::Go(GoDependency { - module_path: "github.com/example/pkg".into(), - module_path_range: range, - version: Some("v1.0.0".into()), - version_range: Some(Range::new(Position::new(5, 36), Position::new(5, 42))), - directive: GoDirective::Require, - indirect: false, - }); + #[test] + fn test_document_state_new_without_parse_result() { + let content = r#"{"dependencies": {"express": "^4.18.0"}}"#.to_string(); + let doc_state = DocumentState::new_without_parse_result("npm", content.clone()); - assert_eq!(go_dep.name_range(), range); + assert_eq!(doc_state.ecosystem_id, "npm"); + assert_eq!(doc_state.ecosystem, Ecosystem::Npm); + assert!(doc_state.parse_result.is_none()); + } } - #[test] - fn test_unified_dependency_go_version_range() { - use deps_go::{GoDependency, GoDirective}; - use tower_lsp_server::ls_types::{Position, Range}; - - let version_range = Range::new(Position::new(5, 36), Position::new(5, 42)); - let go_dep = UnifiedDependency::Go(GoDependency { - module_path: "github.com/example/pkg".into(), - module_path_range: Range::new(Position::new(5, 10), Position::new(5, 35)), - version: Some("v1.0.0".into()), - version_range: Some(version_range), - directive: GoDirective::Require, - indirect: false, - }); - - assert_eq!(go_dep.version_range(), Some(version_range)); - } + // ========================================================================= + // PyPI ecosystem tests + // ========================================================================= - #[test] - fn test_unified_dependency_go_no_version() { - use deps_go::{GoDependency, GoDirective}; + #[cfg(feature = "pypi")] + mod pypi_tests { + use super::*; + use deps_pypi::{PypiDependency, PypiDependencySection, PypiDependencySource}; use tower_lsp_server::ls_types::{Position, Range}; - let go_dep = UnifiedDependency::Go(GoDependency { - module_path: "github.com/example/pkg".into(), - module_path_range: Range::new(Position::new(5, 10), Position::new(5, 35)), - version: None, - version_range: None, - directive: GoDirective::Require, - indirect: false, - }); - - assert_eq!(go_dep.version_req(), None); - assert_eq!(go_dep.version_range(), None); - } - - #[test] - fn test_unified_version_go() { - use deps_go::GoVersion; - - let version = UnifiedVersion::Go(GoVersion { - version: "v1.9.1".into(), - time: Some("2023-07-18T14:30:00Z".into()), - is_pseudo: false, - retracted: false, - }); + #[test] + fn test_unified_dependency() { + let pypi_dep = UnifiedDependency::Pypi(PypiDependency { + name: "requests".into(), + name_range: Range::new(Position::new(0, 0), Position::new(0, 8)), + version_req: Some(">=2.0.0".into()), + version_range: Some(Range::new(Position::new(0, 10), Position::new(0, 18))), + extras: vec![], + extras_range: None, + markers: None, + markers_range: None, + source: PypiDependencySource::PyPI, + section: PypiDependencySection::Dependencies, + }); + + assert_eq!(pypi_dep.name(), "requests"); + assert_eq!(pypi_dep.version_req(), Some(">=2.0.0")); + assert!(pypi_dep.is_registry()); + } - assert_eq!(version.version_string(), "v1.9.1"); - assert!(!version.is_yanked()); - } + #[test] + fn test_unified_version() { + let version = UnifiedVersion::Pypi(deps_pypi::PypiVersion { + version: "2.31.0".into(), + yanked: true, + }); + assert_eq!(version.version_string(), "2.31.0"); + assert!(version.is_yanked()); + } - #[test] - fn test_unified_version_go_retracted() { - use deps_go::GoVersion; - - let version = UnifiedVersion::Go(GoVersion { - version: "v1.0.0".into(), - time: None, - is_pseudo: false, - retracted: true, - }); + #[test] + fn test_document_state_new_without_parse_result() { + let content = "[project]\ndependencies = [\"requests>=2.0.0\"]\n".to_string(); + let doc_state = DocumentState::new_without_parse_result("pypi", content.clone()); - assert_eq!(version.version_string(), "v1.0.0"); - assert!(version.is_yanked()); + assert_eq!(doc_state.ecosystem_id, "pypi"); + assert_eq!(doc_state.ecosystem, Ecosystem::Pypi); + assert!(doc_state.parse_result.is_none()); + } } - #[test] - fn test_unified_version_go_pseudo() { - use deps_go::GoVersion; - - let version = UnifiedVersion::Go(GoVersion { - version: "v0.0.0-20191109021931-daa7c04131f5".into(), - time: Some("2019-11-09T02:19:31Z".into()), - is_pseudo: true, - retracted: false, - }); - - assert_eq!( - version.version_string(), - "v0.0.0-20191109021931-daa7c04131f5" - ); - assert!(!version.is_yanked()); - } + // ========================================================================= + // Go ecosystem tests + // ========================================================================= - #[test] - fn test_document_state_new_go() { - use deps_go::{GoDependency, GoDirective}; + #[cfg(feature = "go")] + mod go_tests { + use super::*; + use deps_go::{GoDependency, GoDirective, GoVersion}; use tower_lsp_server::ls_types::{Position, Range}; - let deps = vec![UnifiedDependency::Go(GoDependency { - module_path: "github.com/gin-gonic/gin".into(), - module_path_range: Range::new(Position::new(0, 0), Position::new(0, 25)), - version: Some("v1.9.1".into()), - version_range: Some(Range::new(Position::new(0, 26), Position::new(0, 32))), - directive: GoDirective::Require, - indirect: false, - })]; - - let state = DocumentState::new(Ecosystem::Go, "test content".into(), deps); - - assert_eq!(state.ecosystem, Ecosystem::Go); - assert_eq!(state.ecosystem_id, "go"); - assert_eq!(state.content, "test content"); - assert_eq!(state.dependencies.len(), 1); - assert!(state.versions.is_empty()); - } - - #[test] - fn test_document_state_new_without_parse_result_go() { - let content = r#"module example.com/myapp - -go 1.21 - -require github.com/gin-gonic/gin v1.9.1 -"# - .to_string(); - - let doc_state = DocumentState::new_without_parse_result("go", content.clone()); - - assert_eq!(doc_state.ecosystem_id, "go"); - assert_eq!(doc_state.ecosystem, Ecosystem::Go); - assert_eq!(doc_state.content, content); - assert!(doc_state.parse_result.is_none()); - assert!(doc_state.dependencies.is_empty()); - } + fn create_test_dependency() -> UnifiedDependency { + UnifiedDependency::Go(GoDependency { + module_path: "github.com/gin-gonic/gin".into(), + module_path_range: Range::new(Position::new(0, 0), Position::new(0, 25)), + version: Some("v1.9.1".into()), + version_range: Some(Range::new(Position::new(0, 26), Position::new(0, 32))), + directive: GoDirective::Require, + indirect: false, + }) + } - #[test] - fn test_document_state_new_from_parse_result_go() { - let state = ServerState::new(); - let uri = Uri::from_file_path("/test/go.mod").unwrap(); - let ecosystem = state.ecosystem_registry.get("go").unwrap(); + #[test] + fn test_unified_dependency() { + let go_dep = create_test_dependency(); + assert_eq!(go_dep.name(), "github.com/gin-gonic/gin"); + assert_eq!(go_dep.version_req(), Some("v1.9.1")); + assert!(go_dep.is_registry()); + } - let content = r#"module example.com/myapp + #[test] + fn test_unified_dependency_name_range() { + let range = Range::new(Position::new(5, 10), Position::new(5, 35)); + let go_dep = UnifiedDependency::Go(GoDependency { + module_path: "github.com/example/pkg".into(), + module_path_range: range, + version: Some("v1.0.0".into()), + version_range: Some(Range::new(Position::new(5, 36), Position::new(5, 42))), + directive: GoDirective::Require, + indirect: false, + }); + assert_eq!(go_dep.name_range(), range); + } -go 1.21 + #[test] + fn test_unified_dependency_version_range() { + let version_range = Range::new(Position::new(5, 36), Position::new(5, 42)); + let go_dep = UnifiedDependency::Go(GoDependency { + module_path: "github.com/example/pkg".into(), + module_path_range: Range::new(Position::new(5, 10), Position::new(5, 35)), + version: Some("v1.0.0".into()), + version_range: Some(version_range), + directive: GoDirective::Require, + indirect: false, + }); + assert_eq!(go_dep.version_range(), Some(version_range)); + } -require github.com/gin-gonic/gin v1.9.1 -"# - .to_string(); + #[test] + fn test_unified_dependency_no_version() { + let go_dep = UnifiedDependency::Go(GoDependency { + module_path: "github.com/example/pkg".into(), + module_path_range: Range::new(Position::new(5, 10), Position::new(5, 35)), + version: None, + version_range: None, + directive: GoDirective::Require, + indirect: false, + }); + assert_eq!(go_dep.version_req(), None); + assert_eq!(go_dep.version_range(), None); + } - let parse_result = tokio::runtime::Runtime::new() - .unwrap() - .block_on(ecosystem.parse_manifest(&content, &uri)) - .unwrap(); + #[test] + fn test_unified_version() { + let version = UnifiedVersion::Go(GoVersion { + version: "v1.9.1".into(), + time: Some("2023-07-18T14:30:00Z".into()), + is_pseudo: false, + retracted: false, + }); + assert_eq!(version.version_string(), "v1.9.1"); + assert!(!version.is_yanked()); + } - let doc_state = DocumentState::new_from_parse_result("go", content.clone(), parse_result); + #[test] + fn test_unified_version_retracted() { + let version = UnifiedVersion::Go(GoVersion { + version: "v1.0.0".into(), + time: None, + is_pseudo: false, + retracted: true, + }); + assert_eq!(version.version_string(), "v1.0.0"); + assert!(version.is_yanked()); + } - assert_eq!(doc_state.ecosystem_id, "go"); - assert_eq!(doc_state.ecosystem, Ecosystem::Go); - assert_eq!(doc_state.content, content); - assert!(doc_state.parse_result.is_some()); - } + #[test] + fn test_unified_version_pseudo() { + let version = UnifiedVersion::Go(GoVersion { + version: "v0.0.0-20191109021931-daa7c04131f5".into(), + time: Some("2019-11-09T02:19:31Z".into()), + is_pseudo: true, + retracted: false, + }); + assert_eq!( + version.version_string(), + "v0.0.0-20191109021931-daa7c04131f5" + ); + assert!(!version.is_yanked()); + } - #[test] - fn test_unified_version_go_version_string_getter() { - use deps_go::GoVersion; - - let version = UnifiedVersion::Go(GoVersion { - version: "v1.9.1".into(), - time: None, - is_pseudo: false, - retracted: false, - }); + #[test] + fn test_document_state_new() { + let deps = vec![create_test_dependency()]; + let state = DocumentState::new(Ecosystem::Go, "test content".into(), deps); - assert_eq!(version.version_string(), "v1.9.1"); - } + assert_eq!(state.ecosystem, Ecosystem::Go); + assert_eq!(state.ecosystem_id, "go"); + assert_eq!(state.dependencies.len(), 1); + } - #[test] - fn test_unified_version_go_yanked_checker() { - use deps_go::GoVersion; - - let retracted = UnifiedVersion::Go(GoVersion { - version: "v1.0.0".into(), - time: None, - is_pseudo: false, - retracted: true, - }); + #[test] + fn test_document_state_new_without_parse_result() { + let content = + "module example.com/myapp\n\ngo 1.21\n\nrequire github.com/gin-gonic/gin v1.9.1\n" + .to_string(); + let doc_state = DocumentState::new_without_parse_result("go", content.clone()); - let normal = UnifiedVersion::Go(GoVersion { - version: "v1.9.1".into(), - time: None, - is_pseudo: false, - retracted: false, - }); + assert_eq!(doc_state.ecosystem_id, "go"); + assert_eq!(doc_state.ecosystem, Ecosystem::Go); + assert!(doc_state.parse_result.is_none()); + } - assert!(retracted.is_yanked()); - assert!(!normal.is_yanked()); + #[test] + fn test_document_state_new_from_parse_result() { + let state = ServerState::new(); + let uri = Uri::from_file_path("/test/go.mod").unwrap(); + let ecosystem = state.ecosystem_registry.get("go").unwrap(); + let content = + "module example.com/myapp\n\ngo 1.21\n\nrequire github.com/gin-gonic/gin v1.9.1\n" + .to_string(); + + let parse_result = tokio::runtime::Runtime::new() + .unwrap() + .block_on(ecosystem.parse_manifest(&content, &uri)) + .unwrap(); + + let doc_state = + DocumentState::new_from_parse_result("go", content.clone(), parse_result); + + assert_eq!(doc_state.ecosystem_id, "go"); + assert!(doc_state.parse_result.is_some()); + } } } diff --git a/crates/deps-lsp/src/handlers/code_actions.rs b/crates/deps-lsp/src/handlers/code_actions.rs index f8b55f95..3bb829e4 100644 --- a/crates/deps-lsp/src/handlers/code_actions.rs +++ b/crates/deps-lsp/src/handlers/code_actions.rs @@ -53,10 +53,12 @@ pub async fn handle_code_actions( #[cfg(test)] mod tests { use super::*; - use crate::document::{DocumentState, ServerState}; + use crate::document::ServerState; use crate::test_utils::test_helpers::create_test_client_and_config; use tower_lsp_server::ls_types::{Position, Range, TextDocumentIdentifier, Uri}; + // Generic tests (no feature flag required) + #[tokio::test] async fn test_handle_code_actions_missing_document() { let state = Arc::new(ServerState::new()); @@ -75,87 +77,99 @@ mod tests { assert!(result.is_empty()); } - #[tokio::test] - async fn test_handle_code_actions_cargo() { - let state = Arc::new(ServerState::new()); - let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); + // Cargo-specific tests + #[cfg(feature = "cargo")] + mod cargo_tests { + use super::*; + use crate::document::{DocumentState, Ecosystem}; - let ecosystem = state.ecosystem_registry.get("cargo").unwrap(); - let content = r#"[dependencies] + #[tokio::test] + async fn test_handle_code_actions() { + let state = Arc::new(ServerState::new()); + let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); + + let ecosystem = state.ecosystem_registry.get("cargo").unwrap(); + let content = r#"[dependencies] serde = "1.0.0" "# - .to_string(); - - let parse_result = ecosystem - .parse_manifest(&content, &uri) - .await - .expect("Failed to parse manifest"); - - let doc_state = DocumentState::new_from_parse_result("cargo", content, parse_result); - state.update_document(uri.clone(), doc_state); - - let params = CodeActionParams { - text_document: TextDocumentIdentifier { uri }, - range: Range::new(Position::new(1, 9), Position::new(1, 16)), - context: Default::default(), - work_done_progress_params: Default::default(), - partial_result_params: Default::default(), - }; - - let (client, config) = create_test_client_and_config(); - let _result = handle_code_actions(state, params, client, config).await; - // Test passes if no panic occurs + .to_string(); + + let parse_result = ecosystem + .parse_manifest(&content, &uri) + .await + .expect("Failed to parse manifest"); + + let doc_state = DocumentState::new_from_parse_result("cargo", content, parse_result); + state.update_document(uri.clone(), doc_state); + + let params = CodeActionParams { + text_document: TextDocumentIdentifier { uri }, + range: Range::new(Position::new(1, 9), Position::new(1, 16)), + context: Default::default(), + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }; + + let (client, config) = create_test_client_and_config(); + let _result = handle_code_actions(state, params, client, config).await; + // Test passes if no panic occurs + } + + #[tokio::test] + async fn test_handle_code_actions_no_parse_result() { + let state = Arc::new(ServerState::new()); + let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); + + let doc_state = DocumentState::new(Ecosystem::Cargo, "".to_string(), vec![]); + state.update_document(uri.clone(), doc_state); + + let params = CodeActionParams { + text_document: TextDocumentIdentifier { uri }, + range: Range::new(Position::new(0, 0), Position::new(0, 0)), + context: Default::default(), + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }; + + let (client, config) = create_test_client_and_config(); + let result = handle_code_actions(state, params, client, config).await; + assert!(result.is_empty()); + } } - #[tokio::test] - async fn test_handle_code_actions_npm() { - let state = Arc::new(ServerState::new()); - let uri = Uri::from_file_path("/test/package.json").unwrap(); - - let ecosystem = state.ecosystem_registry.get("npm").unwrap(); - let content = r#"{"dependencies": {"express": "4.0.0"}}"#.to_string(); - - let parse_result = ecosystem - .parse_manifest(&content, &uri) - .await - .expect("Failed to parse manifest"); - - let doc_state = DocumentState::new_from_parse_result("npm", content, parse_result); - state.update_document(uri.clone(), doc_state); - - let params = CodeActionParams { - text_document: TextDocumentIdentifier { uri }, - range: Range::new(Position::new(0, 25), Position::new(0, 32)), - context: Default::default(), - work_done_progress_params: Default::default(), - partial_result_params: Default::default(), - }; - - let (client, config) = create_test_client_and_config(); - let _result = handle_code_actions(state, params, client, config).await; - // Test passes if no panic occurs - } - - #[tokio::test] - async fn test_handle_code_actions_no_parse_result() { - use crate::document::Ecosystem; - - let state = Arc::new(ServerState::new()); - let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); - - let doc_state = DocumentState::new(Ecosystem::Cargo, "".to_string(), vec![]); - state.update_document(uri.clone(), doc_state); - - let params = CodeActionParams { - text_document: TextDocumentIdentifier { uri }, - range: Range::new(Position::new(0, 0), Position::new(0, 0)), - context: Default::default(), - work_done_progress_params: Default::default(), - partial_result_params: Default::default(), - }; - - let (client, config) = create_test_client_and_config(); - let result = handle_code_actions(state, params, client, config).await; - assert!(result.is_empty()); + // npm-specific tests + #[cfg(feature = "npm")] + mod npm_tests { + use super::*; + use crate::document::DocumentState; + + #[tokio::test] + async fn test_handle_code_actions() { + let state = Arc::new(ServerState::new()); + let uri = Uri::from_file_path("/test/package.json").unwrap(); + + let ecosystem = state.ecosystem_registry.get("npm").unwrap(); + let content = r#"{"dependencies": {"express": "4.0.0"}}"#.to_string(); + + let parse_result = ecosystem + .parse_manifest(&content, &uri) + .await + .expect("Failed to parse manifest"); + + let doc_state = DocumentState::new_from_parse_result("npm", content, parse_result); + state.update_document(uri.clone(), doc_state); + + let params = CodeActionParams { + text_document: TextDocumentIdentifier { uri }, + range: Range::new(Position::new(0, 25), Position::new(0, 32)), + context: Default::default(), + work_done_progress_params: Default::default(), + partial_result_params: Default::default(), + }; + + let (client, config) = create_test_client_and_config(); + let _result = handle_code_actions(state, params, client, config).await; + // Test passes if no panic occurs + } } } diff --git a/crates/deps-lsp/src/handlers/diagnostics.rs b/crates/deps-lsp/src/handlers/diagnostics.rs index dd0ab638..390367b1 100644 --- a/crates/deps-lsp/src/handlers/diagnostics.rs +++ b/crates/deps-lsp/src/handlers/diagnostics.rs @@ -63,9 +63,11 @@ pub(crate) async fn generate_diagnostics_internal( mod tests { use super::*; use crate::config::DiagnosticsConfig; - use crate::document::{DocumentState, Ecosystem, ServerState}; + use crate::document::ServerState; use crate::test_utils::test_helpers::create_test_client_and_config; + // Generic tests (no feature flag required) + #[tokio::test] async fn test_handle_diagnostics_missing_document() { let state = Arc::new(ServerState::new()); @@ -77,89 +79,110 @@ mod tests { assert!(result.is_empty()); } - #[tokio::test] - async fn test_handle_diagnostics_cargo() { - let state = Arc::new(ServerState::new()); - let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); - let config = DiagnosticsConfig::default(); + // Cargo-specific tests + #[cfg(feature = "cargo")] + mod cargo_tests { + use super::*; + use crate::document::{DocumentState, Ecosystem}; - let ecosystem = state.ecosystem_registry.get("cargo").unwrap(); - let content = r#"[dependencies] + #[tokio::test] + async fn test_handle_diagnostics() { + let state = Arc::new(ServerState::new()); + let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); + let config = DiagnosticsConfig::default(); + + let ecosystem = state.ecosystem_registry.get("cargo").unwrap(); + let content = r#"[dependencies] serde = "1.0.0" "# - .to_string(); + .to_string(); - let parse_result = ecosystem - .parse_manifest(&content, &uri) - .await - .expect("Failed to parse manifest"); + let parse_result = ecosystem + .parse_manifest(&content, &uri) + .await + .expect("Failed to parse manifest"); - let doc_state = DocumentState::new_from_parse_result("cargo", content, parse_result); - state.update_document(uri.clone(), doc_state); + let doc_state = DocumentState::new_from_parse_result("cargo", content, parse_result); + state.update_document(uri.clone(), doc_state); - let (client, full_config) = create_test_client_and_config(); - let _result = handle_diagnostics(state, &uri, &config, client, full_config).await; - // Test passes if no panic occurs + let (client, full_config) = create_test_client_and_config(); + let _result = handle_diagnostics(state, &uri, &config, client, full_config).await; + // Test passes if no panic occurs + } + + #[tokio::test] + async fn test_handle_diagnostics_no_parse_result() { + let state = Arc::new(ServerState::new()); + let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); + let config = DiagnosticsConfig::default(); + + let doc_state = DocumentState::new(Ecosystem::Cargo, "".to_string(), vec![]); + state.update_document(uri.clone(), doc_state); + + let (client, full_config) = create_test_client_and_config(); + let result = handle_diagnostics(state, &uri, &config, client, full_config).await; + assert!(result.is_empty()); + } } - #[tokio::test] - async fn test_handle_diagnostics_npm() { - let state = Arc::new(ServerState::new()); - let uri = Uri::from_file_path("/test/package.json").unwrap(); - let config = DiagnosticsConfig::default(); + // npm-specific tests + #[cfg(feature = "npm")] + mod npm_tests { + use super::*; + use crate::document::DocumentState; - let ecosystem = state.ecosystem_registry.get("npm").unwrap(); - let content = r#"{"dependencies": {"express": "4.0.0"}}"#.to_string(); + #[tokio::test] + async fn test_handle_diagnostics() { + let state = Arc::new(ServerState::new()); + let uri = Uri::from_file_path("/test/package.json").unwrap(); + let config = DiagnosticsConfig::default(); - let parse_result = ecosystem - .parse_manifest(&content, &uri) - .await - .expect("Failed to parse manifest"); + let ecosystem = state.ecosystem_registry.get("npm").unwrap(); + let content = r#"{"dependencies": {"express": "4.0.0"}}"#.to_string(); - let doc_state = DocumentState::new_from_parse_result("npm", content, parse_result); - state.update_document(uri.clone(), doc_state); + let parse_result = ecosystem + .parse_manifest(&content, &uri) + .await + .expect("Failed to parse manifest"); - let (client, full_config) = create_test_client_and_config(); - let _result = handle_diagnostics(state, &uri, &config, client, full_config).await; - // Test passes if no panic occurs + let doc_state = DocumentState::new_from_parse_result("npm", content, parse_result); + state.update_document(uri.clone(), doc_state); + + let (client, full_config) = create_test_client_and_config(); + let _result = handle_diagnostics(state, &uri, &config, client, full_config).await; + // Test passes if no panic occurs + } } - #[tokio::test] - async fn test_handle_diagnostics_pypi() { - let state = Arc::new(ServerState::new()); - let uri = Uri::from_file_path("/test/pyproject.toml").unwrap(); - let config = DiagnosticsConfig::default(); + // PyPI-specific tests + #[cfg(feature = "pypi")] + mod pypi_tests { + use super::*; + use crate::document::DocumentState; - let ecosystem = state.ecosystem_registry.get("pypi").unwrap(); - let content = r#"[project] + #[tokio::test] + async fn test_handle_diagnostics() { + let state = Arc::new(ServerState::new()); + let uri = Uri::from_file_path("/test/pyproject.toml").unwrap(); + let config = DiagnosticsConfig::default(); + + let ecosystem = state.ecosystem_registry.get("pypi").unwrap(); + let content = r#"[project] dependencies = ["requests>=2.0.0"] "# - .to_string(); + .to_string(); - let parse_result = ecosystem - .parse_manifest(&content, &uri) - .await - .expect("Failed to parse manifest"); + let parse_result = ecosystem + .parse_manifest(&content, &uri) + .await + .expect("Failed to parse manifest"); - let doc_state = DocumentState::new_from_parse_result("pypi", content, parse_result); - state.update_document(uri.clone(), doc_state); + let doc_state = DocumentState::new_from_parse_result("pypi", content, parse_result); + state.update_document(uri.clone(), doc_state); - let (client, full_config) = create_test_client_and_config(); - let _result = handle_diagnostics(state, &uri, &config, client, full_config).await; - // Test passes if no panic occurs - } - - #[tokio::test] - async fn test_handle_diagnostics_no_parse_result() { - let state = Arc::new(ServerState::new()); - let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); - let config = DiagnosticsConfig::default(); - - let doc_state = DocumentState::new(Ecosystem::Cargo, "".to_string(), vec![]); - state.update_document(uri.clone(), doc_state); - - let (client, full_config) = create_test_client_and_config(); - let result = handle_diagnostics(state, &uri, &config, client, full_config).await; - assert!(result.is_empty()); + let (client, full_config) = create_test_client_and_config(); + let _result = handle_diagnostics(state, &uri, &config, client, full_config).await; + // Test passes if no panic occurs + } } } diff --git a/crates/deps-lsp/src/handlers/hover.rs b/crates/deps-lsp/src/handlers/hover.rs index 69fae997..620fb0f8 100644 --- a/crates/deps-lsp/src/handlers/hover.rs +++ b/crates/deps-lsp/src/handlers/hover.rs @@ -42,12 +42,14 @@ pub async fn handle_hover( #[cfg(test)] mod tests { use super::*; - use crate::document::{DocumentState, Ecosystem, ServerState}; + use crate::document::ServerState; use crate::test_utils::test_helpers::create_test_client_and_config; use tower_lsp_server::ls_types::{ Position, TextDocumentIdentifier, TextDocumentPositionParams, Uri, }; + // Generic tests (no feature flag required) + #[tokio::test] async fn test_handle_hover_missing_document() { let state = Arc::new(ServerState::new()); @@ -66,85 +68,99 @@ mod tests { assert!(result.is_none()); } - #[tokio::test] - async fn test_handle_hover_cargo() { - let state = Arc::new(ServerState::new()); - let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); + // Cargo-specific tests + #[cfg(feature = "cargo")] + mod cargo_tests { + use super::*; + use crate::document::{DocumentState, Ecosystem}; + + #[tokio::test] + async fn test_handle_hover() { + let state = Arc::new(ServerState::new()); + let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); - let ecosystem = state.ecosystem_registry.get("cargo").unwrap(); - let content = r#"[dependencies] + let ecosystem = state.ecosystem_registry.get("cargo").unwrap(); + let content = r#"[dependencies] serde = "1.0.0" "# - .to_string(); - - let parse_result = ecosystem - .parse_manifest(&content, &uri) - .await - .expect("Failed to parse manifest"); - - let doc_state = DocumentState::new_from_parse_result("cargo", content, parse_result); - state.update_document(uri.clone(), doc_state); - - let params = HoverParams { - text_document_position_params: TextDocumentPositionParams { - text_document: TextDocumentIdentifier { uri }, - position: Position::new(1, 0), - }, - work_done_progress_params: Default::default(), - }; - - let (client, config) = create_test_client_and_config(); - let _result = handle_hover(state, params, client, config).await; - // Test passes if no panic occurs - } - - #[tokio::test] - async fn test_handle_hover_npm() { - let state = Arc::new(ServerState::new()); - let uri = Uri::from_file_path("/test/package.json").unwrap(); - - let ecosystem = state.ecosystem_registry.get("npm").unwrap(); - let content = r#"{"dependencies": {"express": "4.0.0"}}"#.to_string(); - - let parse_result = ecosystem - .parse_manifest(&content, &uri) - .await - .expect("Failed to parse manifest"); - - let doc_state = DocumentState::new_from_parse_result("npm", content, parse_result); - state.update_document(uri.clone(), doc_state); - - let params = HoverParams { - text_document_position_params: TextDocumentPositionParams { - text_document: TextDocumentIdentifier { uri }, - position: Position::new(0, 20), - }, - work_done_progress_params: Default::default(), - }; - - let (client, config) = create_test_client_and_config(); - let _result = handle_hover(state, params, client, config).await; - // Test passes if no panic occurs + .to_string(); + + let parse_result = ecosystem + .parse_manifest(&content, &uri) + .await + .expect("Failed to parse manifest"); + + let doc_state = DocumentState::new_from_parse_result("cargo", content, parse_result); + state.update_document(uri.clone(), doc_state); + + let params = HoverParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: Position::new(1, 0), + }, + work_done_progress_params: Default::default(), + }; + + let (client, config) = create_test_client_and_config(); + let _result = handle_hover(state, params, client, config).await; + // Test passes if no panic occurs + } + + #[tokio::test] + async fn test_handle_hover_no_parse_result() { + let state = Arc::new(ServerState::new()); + let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); + + let doc_state = DocumentState::new(Ecosystem::Cargo, "".to_string(), vec![]); + state.update_document(uri.clone(), doc_state); + + let params = HoverParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: Position::new(0, 0), + }, + work_done_progress_params: Default::default(), + }; + + let (client, config) = create_test_client_and_config(); + let result = handle_hover(state, params, client, config).await; + assert!(result.is_none()); + } } - #[tokio::test] - async fn test_handle_hover_no_parse_result() { - let state = Arc::new(ServerState::new()); - let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); - - let doc_state = DocumentState::new(Ecosystem::Cargo, "".to_string(), vec![]); - state.update_document(uri.clone(), doc_state); - - let params = HoverParams { - text_document_position_params: TextDocumentPositionParams { - text_document: TextDocumentIdentifier { uri }, - position: Position::new(0, 0), - }, - work_done_progress_params: Default::default(), - }; - - let (client, config) = create_test_client_and_config(); - let result = handle_hover(state, params, client, config).await; - assert!(result.is_none()); + // npm-specific tests + #[cfg(feature = "npm")] + mod npm_tests { + use super::*; + use crate::document::DocumentState; + + #[tokio::test] + async fn test_handle_hover() { + let state = Arc::new(ServerState::new()); + let uri = Uri::from_file_path("/test/package.json").unwrap(); + + let ecosystem = state.ecosystem_registry.get("npm").unwrap(); + let content = r#"{"dependencies": {"express": "4.0.0"}}"#.to_string(); + + let parse_result = ecosystem + .parse_manifest(&content, &uri) + .await + .expect("Failed to parse manifest"); + + let doc_state = DocumentState::new_from_parse_result("npm", content, parse_result); + state.update_document(uri.clone(), doc_state); + + let params = HoverParams { + text_document_position_params: TextDocumentPositionParams { + text_document: TextDocumentIdentifier { uri }, + position: Position::new(0, 20), + }, + work_done_progress_params: Default::default(), + }; + + let (client, config) = create_test_client_and_config(); + let _result = handle_hover(state, params, client, config).await; + // Test passes if no panic occurs + } } } diff --git a/crates/deps-lsp/src/handlers/inlay_hints.rs b/crates/deps-lsp/src/handlers/inlay_hints.rs index de2526f9..3ba2905a 100644 --- a/crates/deps-lsp/src/handlers/inlay_hints.rs +++ b/crates/deps-lsp/src/handlers/inlay_hints.rs @@ -76,10 +76,12 @@ pub async fn handle_inlay_hints( #[cfg(test)] mod tests { use super::*; - use crate::document::{DocumentState, Ecosystem, ServerState}; + use crate::document::ServerState; use crate::test_utils::test_helpers::create_test_client_and_config; use tower_lsp_server::ls_types::{TextDocumentIdentifier, Uri}; + // Generic tests (no feature flag required) + #[test] fn test_handle_inlay_hints_disabled() { let config = InlayHintsConfig { @@ -139,179 +141,200 @@ mod tests { assert!(result.is_empty()); } - #[tokio::test] - async fn test_handle_inlay_hints_cargo() { - let state = Arc::new(ServerState::new()); - let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); - let config = InlayHintsConfig { - enabled: true, - up_to_date_text: "✅".to_string(), - needs_update_text: "❌ {}".to_string(), - }; - - let ecosystem = state.ecosystem_registry.get("cargo").unwrap(); - let content = r#"[dependencies] + // Cargo-specific tests + #[cfg(feature = "cargo")] + mod cargo_tests { + use super::*; + use crate::document::{DocumentState, Ecosystem}; + + #[tokio::test] + async fn test_handle_inlay_hints() { + let state = Arc::new(ServerState::new()); + let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); + let config = InlayHintsConfig { + enabled: true, + up_to_date_text: "✅".to_string(), + needs_update_text: "❌ {}".to_string(), + }; + + let ecosystem = state.ecosystem_registry.get("cargo").unwrap(); + let content = r#"[dependencies] serde = "1.0.0" "# - .to_string(); - - let parse_result = ecosystem - .parse_manifest(&content, &uri) - .await - .expect("Failed to parse manifest"); - - let doc_state = DocumentState::new_from_parse_result("cargo", content, parse_result); - state.update_document(uri.clone(), doc_state); - - let params = InlayHintParams { - text_document: TextDocumentIdentifier { uri }, - work_done_progress_params: Default::default(), - range: tower_lsp_server::ls_types::Range::new( - tower_lsp_server::ls_types::Position::new(0, 0), - tower_lsp_server::ls_types::Position::new(100, 0), - ), - }; - - let (client, full_config) = create_test_client_and_config(); - let _result = handle_inlay_hints(state, params, &config, client, full_config).await; - // Test passes if no panic occurs - } - - #[tokio::test] - async fn test_handle_inlay_hints_npm() { - let state = Arc::new(ServerState::new()); - let uri = Uri::from_file_path("/test/package.json").unwrap(); - let config = InlayHintsConfig { - enabled: true, - up_to_date_text: "✅".to_string(), - needs_update_text: "❌ {}".to_string(), - }; - - let ecosystem = state.ecosystem_registry.get("npm").unwrap(); - let content = r#"{"dependencies": {"express": "4.0.0"}}"#.to_string(); - - let parse_result = ecosystem - .parse_manifest(&content, &uri) - .await - .expect("Failed to parse manifest"); - - let doc_state = DocumentState::new_from_parse_result("npm", content, parse_result); - state.update_document(uri.clone(), doc_state); - - let params = InlayHintParams { - text_document: TextDocumentIdentifier { uri }, - work_done_progress_params: Default::default(), - range: tower_lsp_server::ls_types::Range::new( - tower_lsp_server::ls_types::Position::new(0, 0), - tower_lsp_server::ls_types::Position::new(100, 0), - ), - }; - - let (client, full_config) = create_test_client_and_config(); - let _result = handle_inlay_hints(state, params, &config, client, full_config).await; - // Test passes if no panic occurs - } + .to_string(); + + let parse_result = ecosystem + .parse_manifest(&content, &uri) + .await + .expect("Failed to parse manifest"); + + let doc_state = DocumentState::new_from_parse_result("cargo", content, parse_result); + state.update_document(uri.clone(), doc_state); + + let params = InlayHintParams { + text_document: TextDocumentIdentifier { uri }, + work_done_progress_params: Default::default(), + range: tower_lsp_server::ls_types::Range::new( + tower_lsp_server::ls_types::Position::new(0, 0), + tower_lsp_server::ls_types::Position::new(100, 0), + ), + }; + + let (client, full_config) = create_test_client_and_config(); + let _result = handle_inlay_hints(state, params, &config, client, full_config).await; + // Test passes if no panic occurs + } - #[tokio::test] - async fn test_handle_inlay_hints_pypi() { - let state = Arc::new(ServerState::new()); - let uri = Uri::from_file_path("/test/pyproject.toml").unwrap(); - let config = InlayHintsConfig { - enabled: true, - up_to_date_text: "✅".to_string(), - needs_update_text: "❌ {}".to_string(), - }; + #[tokio::test] + async fn test_handle_inlay_hints_no_parse_result() { + let state = Arc::new(ServerState::new()); + let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); + let config = InlayHintsConfig { + enabled: true, + up_to_date_text: "✅".to_string(), + needs_update_text: "❌ {}".to_string(), + }; + + let doc_state = DocumentState::new(Ecosystem::Cargo, "".to_string(), vec![]); + state.update_document(uri.clone(), doc_state); + + let params = InlayHintParams { + text_document: TextDocumentIdentifier { uri }, + work_done_progress_params: Default::default(), + range: tower_lsp_server::ls_types::Range::new( + tower_lsp_server::ls_types::Position::new(0, 0), + tower_lsp_server::ls_types::Position::new(100, 0), + ), + }; + + let (client, full_config) = create_test_client_and_config(); + let result = handle_inlay_hints(state, params, &config, client, full_config).await; + assert!(result.is_empty()); + } - let ecosystem = state.ecosystem_registry.get("pypi").unwrap(); - let content = r#"[project] -dependencies = ["requests>=2.0.0"] + #[tokio::test] + async fn test_handle_inlay_hints_custom_config() { + let state = Arc::new(ServerState::new()); + let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); + let config = InlayHintsConfig { + enabled: true, + up_to_date_text: "OK".to_string(), + needs_update_text: "UPDATE: {}".to_string(), + }; + + let ecosystem = state.ecosystem_registry.get("cargo").unwrap(); + let content = r#"[dependencies] +serde = "1.0.0" "# - .to_string(); - - let parse_result = ecosystem - .parse_manifest(&content, &uri) - .await - .expect("Failed to parse manifest"); - - let doc_state = DocumentState::new_from_parse_result("pypi", content, parse_result); - state.update_document(uri.clone(), doc_state); - - let params = InlayHintParams { - text_document: TextDocumentIdentifier { uri }, - work_done_progress_params: Default::default(), - range: tower_lsp_server::ls_types::Range::new( - tower_lsp_server::ls_types::Position::new(0, 0), - tower_lsp_server::ls_types::Position::new(100, 0), - ), - }; - - let (client, full_config) = create_test_client_and_config(); - let _result = handle_inlay_hints(state, params, &config, client, full_config).await; - // Test passes if no panic occurs + .to_string(); + + let parse_result = ecosystem + .parse_manifest(&content, &uri) + .await + .expect("Failed to parse manifest"); + + let doc_state = DocumentState::new_from_parse_result("cargo", content, parse_result); + state.update_document(uri.clone(), doc_state); + + let params = InlayHintParams { + text_document: TextDocumentIdentifier { uri }, + work_done_progress_params: Default::default(), + range: tower_lsp_server::ls_types::Range::new( + tower_lsp_server::ls_types::Position::new(0, 0), + tower_lsp_server::ls_types::Position::new(100, 0), + ), + }; + + let (client, full_config) = create_test_client_and_config(); + let _result = handle_inlay_hints(state, params, &config, client, full_config).await; + // Test passes if no panic occurs + } } - #[tokio::test] - async fn test_handle_inlay_hints_no_parse_result() { - let state = Arc::new(ServerState::new()); - let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); - let config = InlayHintsConfig { - enabled: true, - up_to_date_text: "✅".to_string(), - needs_update_text: "❌ {}".to_string(), - }; - - let doc_state = DocumentState::new(Ecosystem::Cargo, "".to_string(), vec![]); - state.update_document(uri.clone(), doc_state); - - let params = InlayHintParams { - text_document: TextDocumentIdentifier { uri }, - work_done_progress_params: Default::default(), - range: tower_lsp_server::ls_types::Range::new( - tower_lsp_server::ls_types::Position::new(0, 0), - tower_lsp_server::ls_types::Position::new(100, 0), - ), - }; - - let (client, full_config) = create_test_client_and_config(); - let result = handle_inlay_hints(state, params, &config, client, full_config).await; - assert!(result.is_empty()); + // npm-specific tests + #[cfg(feature = "npm")] + mod npm_tests { + use super::*; + use crate::document::DocumentState; + + #[tokio::test] + async fn test_handle_inlay_hints() { + let state = Arc::new(ServerState::new()); + let uri = Uri::from_file_path("/test/package.json").unwrap(); + let config = InlayHintsConfig { + enabled: true, + up_to_date_text: "✅".to_string(), + needs_update_text: "❌ {}".to_string(), + }; + + let ecosystem = state.ecosystem_registry.get("npm").unwrap(); + let content = r#"{"dependencies": {"express": "4.0.0"}}"#.to_string(); + + let parse_result = ecosystem + .parse_manifest(&content, &uri) + .await + .expect("Failed to parse manifest"); + + let doc_state = DocumentState::new_from_parse_result("npm", content, parse_result); + state.update_document(uri.clone(), doc_state); + + let params = InlayHintParams { + text_document: TextDocumentIdentifier { uri }, + work_done_progress_params: Default::default(), + range: tower_lsp_server::ls_types::Range::new( + tower_lsp_server::ls_types::Position::new(0, 0), + tower_lsp_server::ls_types::Position::new(100, 0), + ), + }; + + let (client, full_config) = create_test_client_and_config(); + let _result = handle_inlay_hints(state, params, &config, client, full_config).await; + // Test passes if no panic occurs + } } - #[tokio::test] - async fn test_handle_inlay_hints_custom_config() { - let state = Arc::new(ServerState::new()); - let uri = Uri::from_file_path("/test/Cargo.toml").unwrap(); - let config = InlayHintsConfig { - enabled: true, - up_to_date_text: "OK".to_string(), - needs_update_text: "UPDATE: {}".to_string(), - }; - - let ecosystem = state.ecosystem_registry.get("cargo").unwrap(); - let content = r#"[dependencies] -serde = "1.0.0" + // PyPI-specific tests + #[cfg(feature = "pypi")] + mod pypi_tests { + use super::*; + use crate::document::DocumentState; + + #[tokio::test] + async fn test_handle_inlay_hints() { + let state = Arc::new(ServerState::new()); + let uri = Uri::from_file_path("/test/pyproject.toml").unwrap(); + let config = InlayHintsConfig { + enabled: true, + up_to_date_text: "✅".to_string(), + needs_update_text: "❌ {}".to_string(), + }; + + let ecosystem = state.ecosystem_registry.get("pypi").unwrap(); + let content = r#"[project] +dependencies = ["requests>=2.0.0"] "# - .to_string(); - - let parse_result = ecosystem - .parse_manifest(&content, &uri) - .await - .expect("Failed to parse manifest"); - - let doc_state = DocumentState::new_from_parse_result("cargo", content, parse_result); - state.update_document(uri.clone(), doc_state); - - let params = InlayHintParams { - text_document: TextDocumentIdentifier { uri }, - work_done_progress_params: Default::default(), - range: tower_lsp_server::ls_types::Range::new( - tower_lsp_server::ls_types::Position::new(0, 0), - tower_lsp_server::ls_types::Position::new(100, 0), - ), - }; - - let (client, full_config) = create_test_client_and_config(); - let _result = handle_inlay_hints(state, params, &config, client, full_config).await; - // Test passes if no panic occurs + .to_string(); + + let parse_result = ecosystem + .parse_manifest(&content, &uri) + .await + .expect("Failed to parse manifest"); + + let doc_state = DocumentState::new_from_parse_result("pypi", content, parse_result); + state.update_document(uri.clone(), doc_state); + + let params = InlayHintParams { + text_document: TextDocumentIdentifier { uri }, + work_done_progress_params: Default::default(), + range: tower_lsp_server::ls_types::Range::new( + tower_lsp_server::ls_types::Position::new(0, 0), + tower_lsp_server::ls_types::Position::new(100, 0), + ), + }; + + let (client, full_config) = create_test_client_and_config(); + let _result = handle_inlay_hints(state, params, &config, client, full_config).await; + // Test passes if no panic occurs + } } } diff --git a/crates/deps-lsp/src/lib.rs b/crates/deps-lsp/src/lib.rs index b8b020ae..e793eee5 100644 --- a/crates/deps-lsp/src/lib.rs +++ b/crates/deps-lsp/src/lib.rs @@ -11,16 +11,30 @@ mod test_utils; pub use deps_core::{DepsError, Result}; // Re-export from deps-cargo +#[cfg(feature = "cargo")] pub use deps_cargo::{ CargoParser, CargoVersion, CrateInfo, CratesIoRegistry, DependencySection, DependencySource, ParseResult, ParsedDependency, parse_cargo_toml, }; // Re-export from deps-npm +#[cfg(feature = "npm")] pub use deps_npm::{ NpmDependency, NpmDependencySection, NpmPackage, NpmParseResult, NpmRegistry, NpmVersion, parse_package_json, }; +// Re-export from deps-pypi +#[cfg(feature = "pypi")] +pub use deps_pypi::{ + PypiDependency, PypiDependencySection, PypiEcosystem, PypiParser, PypiRegistry, PypiVersion, +}; + +// Re-export from deps-go +#[cfg(feature = "go")] +pub use deps_go::{ + GoDependency, GoDirective, GoEcosystem, GoParseResult, GoRegistry, GoVersion, parse_go_mod, +}; + // Re-export server pub use server::Backend; diff --git a/crates/deps-npm/README.md b/crates/deps-npm/README.md index 05b3750a..73a3f8ed 100644 --- a/crates/deps-npm/README.md +++ b/crates/deps-npm/README.md @@ -22,7 +22,7 @@ This crate provides parsing and registry integration for the npm ecosystem. ```toml [dependencies] -deps-npm = "0.4" +deps-npm = "0.5" ``` ```rust diff --git a/crates/deps-pypi/README.md b/crates/deps-pypi/README.md index d68c0186..473344f9 100644 --- a/crates/deps-pypi/README.md +++ b/crates/deps-pypi/README.md @@ -24,7 +24,7 @@ This crate provides parsing and registry integration for Python's PyPI ecosystem ```toml [dependencies] -deps-pypi = "0.4" +deps-pypi = "0.5" ``` ```rust diff --git a/docs/ECOSYSTEM_GUIDE.md b/docs/ECOSYSTEM_GUIDE.md index 59adad76..dd1d8021 100644 --- a/docs/ECOSYSTEM_GUIDE.md +++ b/docs/ECOSYSTEM_GUIDE.md @@ -13,10 +13,11 @@ crates/deps-{ecosystem}/ ├── lib.rs # Re-exports and module declarations ├── ecosystem.rs # Ecosystem trait implementation ├── error.rs # Ecosystem-specific error types + ├── formatter.rs # Version display formatting + ├── lockfile.rs # Lock file parsing ├── parser.rs # Manifest file parsing with position tracking ├── registry.rs # Package registry API client - ├── types.rs # Dependency, Version, and other types - └── lockfile.rs # Lock file parsing (optional) + └── types.rs # Dependency, Version, and other types ``` ## Step 1: Create the Crate @@ -663,6 +664,8 @@ Before submitting a PR for a new ecosystem: - [ ] Error types with conversions to `deps_core::DepsError` - [ ] Types implementing `Dependency` and `Version` traits - [ ] Parser with accurate position tracking for names AND versions +- [ ] Lock file parser implementing `LockFileProvider` trait +- [ ] Formatter implementing `EcosystemFormatter` trait - [ ] Registry client with HTTP caching - [ ] Ecosystem trait implementation with all LSP features - [ ] Unit tests for parser edge cases @@ -677,3 +680,8 @@ See existing implementations for reference: - `crates/deps-cargo/` - Rust/Cargo.toml with crates.io - `crates/deps-npm/` - JavaScript/package.json with npm - `crates/deps-pypi/` - Python/pyproject.toml with PyPI +- `crates/deps-go/` - Go/go.mod with proxy.golang.org + +## Templates + +Use the templates in `templates/deps-ecosystem/` as a starting point for new ecosystems. diff --git a/templates/README.md b/templates/README.md index b0333bd3..c6e5b034 100644 --- a/templates/README.md +++ b/templates/README.md @@ -17,6 +17,7 @@ This directory contains template files for creating new ecosystem support in dep | `{MANIFEST_FILE}` | Manifest filename | `pom.xml`, `go.mod`, `packages.config` | | `{REGISTRY_NAME}` | Registry name | `Maven Central`, `proxy.golang.org`, `NuGet.org` | | `{REGISTRY_URL}` | Registry API URL | `https://search.maven.org/...` | +| `{LOCK_FILE}` | Lock file name | `pom.xml.lock`, `go.sum`, `packages.lock.json` | 4. Implement the TODO sections in each file 5. Add your crate to the workspace in `Cargo.toml` @@ -32,7 +33,9 @@ deps-ecosystem/ ├── error.rs.template # Error types ├── types.rs.template # Dependency/Version types ├── parser.rs.template # Manifest parser (IMPORTANT!) + ├── lockfile.rs.template # Lock file parser ├── registry.rs.template # Package registry client + ├── formatter.rs.template # Version display formatting └── ecosystem.rs.template # Main Ecosystem trait impl ``` diff --git a/templates/deps-ecosystem/src/ecosystem.rs.template b/templates/deps-ecosystem/src/ecosystem.rs.template index a36d7c10..8b977fb7 100644 --- a/templates/deps-ecosystem/src/ecosystem.rs.template +++ b/templates/deps-ecosystem/src/ecosystem.rs.template @@ -8,10 +8,12 @@ use tower_lsp_server::ls_types::{ use deps_core::{ Ecosystem, EcosystemConfig, HttpCache, ParseResult as ParseResultTrait, Registry, Result, + lockfile::LockFileProvider, lsp_helpers, }; use crate::formatter::{ECOSYSTEM_PASCAL}Formatter; +use crate::lockfile::{ECOSYSTEM_PASCAL}LockfileParser; use crate::parser::parse_{ECOSYSTEM_SNAKE}; use crate::registry::{ECOSYSTEM_PASCAL}Registry; @@ -56,6 +58,10 @@ impl Ecosystem for {ECOSYSTEM_PASCAL}Ecosystem { self.registry.clone() } + fn lockfile_provider(&self) -> Option> { + Some(Arc::new({ECOSYSTEM_PASCAL}LockfileParser)) + } + async fn generate_inlay_hints( &self, parse_result: &dyn ParseResultTrait, diff --git a/templates/deps-ecosystem/src/lib.rs.template b/templates/deps-ecosystem/src/lib.rs.template index eeb4005b..672f25a2 100644 --- a/templates/deps-ecosystem/src/lib.rs.template +++ b/templates/deps-ecosystem/src/lib.rs.template @@ -1,6 +1,9 @@ +//! {ECOSYSTEM_DISPLAY} ecosystem support for deps-lsp. + pub mod ecosystem; pub mod error; pub mod formatter; +pub mod lockfile; pub mod parser; pub mod registry; pub mod types; @@ -8,6 +11,7 @@ pub mod types; pub use ecosystem::{ECOSYSTEM_PASCAL}Ecosystem; pub use error::{ECOSYSTEM_PASCAL}Error, Result; pub use formatter::{ECOSYSTEM_PASCAL}Formatter; +pub use lockfile::{ECOSYSTEM_PASCAL}LockfileParser; pub use parser::parse_{ECOSYSTEM_SNAKE}; pub use registry::{ECOSYSTEM_PASCAL}Registry; pub use types::{ECOSYSTEM_PASCAL}Dependency, {ECOSYSTEM_PASCAL}Version; diff --git a/templates/deps-ecosystem/src/lockfile.rs.template b/templates/deps-ecosystem/src/lockfile.rs.template new file mode 100644 index 00000000..89559f94 --- /dev/null +++ b/templates/deps-ecosystem/src/lockfile.rs.template @@ -0,0 +1,81 @@ +//! Lock file parsing for {ECOSYSTEM_DISPLAY}. + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use async_trait::async_trait; +use deps_core::lockfile::{LockFileProvider, LockedPackage}; +use tokio::fs; + +/// Lock file parser for {ECOSYSTEM_DISPLAY}. +pub struct {ECOSYSTEM_PASCAL}LockfileParser; + +#[async_trait] +impl LockFileProvider for {ECOSYSTEM_PASCAL}LockfileParser { + fn lock_filename(&self) -> &'static str { + "{LOCK_FILE}" + } + + async fn parse_lockfile( + &self, + manifest_dir: &Path, + ) -> deps_core::Result> { + let lock_path = manifest_dir.join(self.lock_filename()); + + if !lock_path.exists() { + return Ok(HashMap::new()); + } + + let content = fs::read_to_string(&lock_path) + .await + .map_err(|e| deps_core::DepsError::Io(e))?; + + parse_lock_content(&content) + } + + async fn is_stale(&self, manifest_dir: &Path) -> bool { + let lock_path = manifest_dir.join(self.lock_filename()); + let manifest_path = manifest_dir.join("{MANIFEST_FILE}"); + + match (fs::metadata(&lock_path).await, fs::metadata(&manifest_path).await) { + (Ok(lock_meta), Ok(manifest_meta)) => { + match (lock_meta.modified(), manifest_meta.modified()) { + (Ok(lock_time), Ok(manifest_time)) => manifest_time > lock_time, + _ => false, + } + } + _ => false, + } + } +} + +/// Parse lock file content into package map. +fn parse_lock_content(content: &str) -> deps_core::Result> { + let mut packages = HashMap::new(); + + // TODO: Implement lock file parsing logic + // Key requirements: + // 1. Parse each locked package entry + // 2. Extract package name and resolved version + // 3. Handle ecosystem-specific format + + Ok(packages) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_empty_lockfile() { + let result = parse_lock_content("").unwrap(); + assert!(result.is_empty()); + } + + #[tokio::test] + async fn test_lockfile_provider_interface() { + let parser = {ECOSYSTEM_PASCAL}LockfileParser; + assert_eq!(parser.lock_filename(), "{LOCK_FILE}"); + } +}