diff --git a/Cargo.lock b/Cargo.lock index 8b2e4e88..d9c463e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -390,6 +390,7 @@ dependencies = [ "semver", "serde", "serde_json", + "tempfile", "tokio", "toml_edit", "tower-lsp", @@ -451,6 +452,7 @@ dependencies = [ "node-semver", "serde", "serde_json", + "tempfile", "tokio", "tower-lsp", "tracing", @@ -470,6 +472,7 @@ dependencies = [ "pep508_rs", "serde", "serde_json", + "tempfile", "thiserror 2.0.17", "tokio", "toml_edit", diff --git a/Cargo.toml b/Cargo.toml index c9abeb10..5756c3bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ reqwest = { version = "0.12", default-features = false } semver = "1" serde = "1" serde_json = "1" +tempfile = "3" thiserror = "2" tokio = "1.48" tokio-test = "0.4" diff --git a/crates/deps-cargo/Cargo.toml b/crates/deps-cargo/Cargo.toml index f22d1c4c..00419cdc 100644 --- a/crates/deps-cargo/Cargo.toml +++ b/crates/deps-cargo/Cargo.toml @@ -14,6 +14,7 @@ async-trait = { workspace = true } semver = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +tokio = { workspace = true, features = ["fs"] } toml_edit = { workspace = true } tower-lsp = { workspace = true } tracing = { workspace = true } @@ -22,6 +23,7 @@ urlencoding = { workspace = true } [dev-dependencies] criterion = { workspace = true, features = ["html_reports"] } insta = { workspace = true, features = ["json"] } +tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [[bench]] diff --git a/crates/deps-cargo/src/lib.rs b/crates/deps-cargo/src/lib.rs index 8fd0a35e..76d0bdd9 100644 --- a/crates/deps-cargo/src/lib.rs +++ b/crates/deps-cargo/src/lib.rs @@ -22,12 +22,14 @@ //! ``` pub mod handler; +pub mod lockfile; pub mod parser; pub mod registry; pub mod types; // Re-export commonly used types pub use handler::CargoHandler; +pub use lockfile::CargoLockParser; pub use parser::{CargoParser, ParseResult, parse_cargo_toml}; pub use registry::{CratesIoRegistry, crate_url}; pub use types::{CargoVersion, CrateInfo, DependencySection, DependencySource, ParsedDependency}; diff --git a/crates/deps-cargo/src/lockfile.rs b/crates/deps-cargo/src/lockfile.rs new file mode 100644 index 00000000..b35792cf --- /dev/null +++ b/crates/deps-cargo/src/lockfile.rs @@ -0,0 +1,502 @@ +//! Cargo.lock file parsing. +//! +//! Parses Cargo.lock files (version 3 and 4) to extract resolved dependency +//! versions. Supports workspace lock files and proper path resolution. +//! +//! # Cargo.lock Format +//! +//! Cargo.lock uses TOML format with an array of packages: +//! +//! ```toml +//! # This file is automatically @generated by Cargo. +//! # It is not intended for manual editing. +//! version = 4 +//! +//! [[package]] +//! name = "serde" +//! version = "1.0.195" +//! source = "registry+https://github.com/rust-lang/crates.io-index" +//! checksum = "..." +//! dependencies = [ +//! "serde_derive", +//! ] +//! ``` + +use async_trait::async_trait; +use deps_core::error::{DepsError, Result}; +use deps_core::lockfile::{LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource}; +use std::path::{Path, PathBuf}; +use toml_edit::DocumentMut; +use tower_lsp::lsp_types::Url; + +/// Cargo.lock file parser. +/// +/// Implements lock file parsing for Rust's Cargo build system. +/// Supports both project-level and workspace-level lock files. +/// +/// # Lock File Location +/// +/// The parser searches for Cargo.lock in the following order: +/// 1. Same directory as Cargo.toml +/// 2. Parent directories (up to 5 levels) for workspace root +/// +/// # Examples +/// +/// ```no_run +/// use deps_cargo::lockfile::CargoLockParser; +/// use deps_core::lockfile::LockFileProvider; +/// use tower_lsp::lsp_types::Url; +/// +/// # async fn example() -> deps_core::error::Result<()> { +/// let parser = CargoLockParser; +/// let manifest_uri = Url::parse("file:///path/to/Cargo.toml").unwrap(); +/// +/// if let Some(lockfile_path) = parser.locate_lockfile(&manifest_uri) { +/// let resolved = parser.parse_lockfile(&lockfile_path).await?; +/// println!("Found {} resolved packages", resolved.len()); +/// } +/// # Ok(()) +/// # } +/// ``` +pub struct CargoLockParser; + +impl CargoLockParser { + /// Maximum depth to search for workspace root lock file. + const MAX_WORKSPACE_DEPTH: usize = 5; +} + +#[async_trait] +impl LockFileProvider for CargoLockParser { + fn locate_lockfile(&self, manifest_uri: &Url) -> Option { + let manifest_path = manifest_uri.to_file_path().ok()?; + + // Try same directory as manifest + let lock_path = manifest_path.with_file_name("Cargo.lock"); + if lock_path.exists() { + tracing::debug!("Found Cargo.lock at: {}", lock_path.display()); + return Some(lock_path); + } + + // Search up the directory tree for workspace root + let mut current_dir = manifest_path.parent()?; + + for depth in 0..Self::MAX_WORKSPACE_DEPTH { + let workspace_lock = current_dir.join("Cargo.lock"); + if workspace_lock.exists() { + tracing::debug!( + "Found workspace Cargo.lock at depth {}: {}", + depth + 1, + workspace_lock.display() + ); + return Some(workspace_lock); + } + + current_dir = current_dir.parent()?; + } + + tracing::debug!("No Cargo.lock found for: {}", manifest_uri); + None + } + + async fn parse_lockfile(&self, lockfile_path: &Path) -> Result { + tracing::debug!("Parsing Cargo.lock: {}", lockfile_path.display()); + + let content = tokio::fs::read_to_string(lockfile_path) + .await + .map_err(|e| DepsError::ParseError { + file_type: format!("Cargo.lock at {}", lockfile_path.display()), + source: Box::new(e), + })?; + + let doc: DocumentMut = content.parse().map_err(|e| DepsError::ParseError { + file_type: "Cargo.lock".into(), + source: Box::new(e), + })?; + + let mut packages = ResolvedPackages::new(); + + let Some(package_array) = doc + .get("package") + .and_then(|v: &toml_edit::Item| v.as_array_of_tables()) + else { + tracing::warn!("Cargo.lock missing [[package]] array of tables"); + return Ok(packages); + }; + + for table in package_array.iter() { + // Extract required fields + let Some(name) = table.get("name").and_then(|v: &toml_edit::Item| v.as_str()) else { + tracing::warn!("Package missing name field"); + continue; + }; + + let Some(version) = table + .get("version") + .and_then(|v: &toml_edit::Item| v.as_str()) + else { + tracing::warn!("Package '{}' missing version field", name); + continue; + }; + + // Parse source (optional for path dependencies) + let source = parse_cargo_source( + table + .get("source") + .and_then(|v: &toml_edit::Item| v.as_str()), + ); + + // Parse dependencies array (optional) + let dependencies = parse_cargo_dependencies_from_table(table); + + packages.insert(ResolvedPackage { + name: name.to_string(), + version: version.to_string(), + source, + dependencies, + }); + } + + tracing::info!( + "Parsed Cargo.lock: {} packages from {}", + packages.len(), + lockfile_path.display() + ); + + Ok(packages) + } +} + +/// Parses Cargo source field into ResolvedSource. +/// +/// # Source Formats +/// +/// - `"registry+https://github.com/rust-lang/crates.io-index"` → Registry +/// - `"git+https://github.com/user/repo#commit"` → Git +/// - None (path dependencies don't have source field) → Path +fn parse_cargo_source(source_str: Option<&str>) -> ResolvedSource { + let Some(source) = source_str else { + return ResolvedSource::Path { + path: String::new(), + }; + }; + + if let Some(registry_url) = source.strip_prefix("registry+") { + ResolvedSource::Registry { + url: registry_url.to_string(), + checksum: String::new(), + } + } else if let Some(git_part) = source.strip_prefix("git+") { + let (url, rev) = if let Some((u, r)) = git_part.split_once('#') { + (u.to_string(), r.to_string()) + } else { + (git_part.to_string(), String::new()) + }; + + ResolvedSource::Git { url, rev } + } else { + ResolvedSource::Path { + path: source.to_string(), + } + } +} + +/// Parses dependencies array from package table. +/// +/// Dependencies are typically simple strings in Cargo.lock v4: +/// ```toml +/// dependencies = ["serde_derive", "syn"] +/// ``` +fn parse_cargo_dependencies_from_table(table: &toml_edit::Table) -> Vec { + let Some(deps_value) = table.get("dependencies") else { + return vec![]; + }; + + let Some(deps_array) = deps_value.as_array() else { + return vec![]; + }; + + deps_array + .iter() + .filter_map(|item| { + // Simple string format (most common) + if let Some(s) = item.as_str() { + return Some(s.to_string()); + } + + // Inline table format (rare, extract "name" field) + if let Some(table) = item.as_inline_table() + && let Some(name) = table.get("name").and_then(|v| v.as_str()) + { + return Some(name.to_string()); + } + + None + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_cargo_source_registry() { + let source = parse_cargo_source(Some( + "registry+https://github.com/rust-lang/crates.io-index", + )); + + match source { + ResolvedSource::Registry { url, .. } => { + assert_eq!(url, "https://github.com/rust-lang/crates.io-index"); + } + _ => panic!("Expected Registry source"), + } + } + + #[test] + fn test_parse_cargo_source_git() { + let source = parse_cargo_source(Some("git+https://github.com/user/repo#abc123")); + + match source { + ResolvedSource::Git { url, rev } => { + assert_eq!(url, "https://github.com/user/repo"); + assert_eq!(rev, "abc123"); + } + _ => panic!("Expected Git source"), + } + } + + #[test] + fn test_parse_cargo_source_git_no_commit() { + let source = parse_cargo_source(Some("git+https://github.com/user/repo")); + + match source { + ResolvedSource::Git { url, rev } => { + assert_eq!(url, "https://github.com/user/repo"); + assert!(rev.is_empty()); + } + _ => panic!("Expected Git source"), + } + } + + #[test] + fn test_parse_cargo_source_path() { + let source = parse_cargo_source(None); + + match source { + ResolvedSource::Path { path } => { + assert!(path.is_empty()); + } + _ => panic!("Expected Path source"), + } + } + + #[tokio::test] + async fn test_parse_simple_cargo_lock() { + let lockfile_content = r#" +# This file is automatically @generated by Cargo. +version = 4 + +[[package]] +name = "serde" +version = "1.0.195" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc123" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.195" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def456" +"#; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("Cargo.lock"); + std::fs::write(&lockfile_path, lockfile_content).unwrap(); + + let parser = CargoLockParser; + let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap(); + + assert_eq!(resolved.len(), 2); + assert_eq!(resolved.get_version("serde"), Some("1.0.195")); + assert_eq!(resolved.get_version("serde_derive"), Some("1.0.195")); + + let serde_pkg = resolved.get("serde").unwrap(); + assert_eq!(serde_pkg.dependencies.len(), 1); + assert_eq!(serde_pkg.dependencies[0], "serde_derive"); + } + + #[tokio::test] + async fn test_parse_cargo_lock_with_git() { + let lockfile_content = r#" +version = 4 + +[[package]] +name = "my-git-dep" +version = "0.1.0" +source = "git+https://github.com/user/repo#abc123" +"#; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("Cargo.lock"); + std::fs::write(&lockfile_path, lockfile_content).unwrap(); + + let parser = CargoLockParser; + let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap(); + + assert_eq!(resolved.len(), 1); + let pkg = resolved.get("my-git-dep").unwrap(); + assert_eq!(pkg.version, "0.1.0"); + + match &pkg.source { + ResolvedSource::Git { url, rev } => { + assert_eq!(url, "https://github.com/user/repo"); + assert_eq!(rev, "abc123"); + } + _ => panic!("Expected Git source"), + } + } + + #[tokio::test] + async fn test_parse_empty_cargo_lock() { + let lockfile_content = r#" +version = 4 +"#; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("Cargo.lock"); + std::fs::write(&lockfile_path, lockfile_content).unwrap(); + + let parser = CargoLockParser; + let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap(); + + assert_eq!(resolved.len(), 0); + assert!(resolved.is_empty()); + } + + #[tokio::test] + async fn test_parse_malformed_cargo_lock() { + let lockfile_content = "not valid toml {{{"; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("Cargo.lock"); + std::fs::write(&lockfile_path, lockfile_content).unwrap(); + + let parser = CargoLockParser; + let result = parser.parse_lockfile(&lockfile_path).await; + + assert!(result.is_err()); + } + + #[test] + fn test_locate_lockfile_same_directory() { + let temp_dir = tempfile::tempdir().unwrap(); + let manifest_path = temp_dir.path().join("Cargo.toml"); + let lock_path = temp_dir.path().join("Cargo.lock"); + + std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap(); + std::fs::write(&lock_path, "version = 4").unwrap(); + + let manifest_uri = Url::from_file_path(&manifest_path).unwrap(); + let parser = CargoLockParser; + + let located = parser.locate_lockfile(&manifest_uri); + assert!(located.is_some()); + assert_eq!(located.unwrap(), lock_path); + } + + #[test] + fn test_locate_lockfile_workspace_root() { + let temp_dir = tempfile::tempdir().unwrap(); + let workspace_lock = temp_dir.path().join("Cargo.lock"); + let member_dir = temp_dir.path().join("crates").join("member"); + std::fs::create_dir_all(&member_dir).unwrap(); + let member_manifest = member_dir.join("Cargo.toml"); + + std::fs::write(&workspace_lock, "version = 4").unwrap(); + std::fs::write(&member_manifest, "[package]\nname = \"member\"").unwrap(); + + let manifest_uri = Url::from_file_path(&member_manifest).unwrap(); + let parser = CargoLockParser; + + let located = parser.locate_lockfile(&manifest_uri); + assert!(located.is_some()); + assert_eq!(located.unwrap(), workspace_lock); + } + + #[test] + fn test_locate_lockfile_not_found() { + let temp_dir = tempfile::tempdir().unwrap(); + let manifest_path = temp_dir.path().join("Cargo.toml"); + std::fs::write(&manifest_path, "[package]\nname = \"test\"").unwrap(); + + let manifest_uri = Url::from_file_path(&manifest_path).unwrap(); + let parser = CargoLockParser; + + let located = parser.locate_lockfile(&manifest_uri); + assert!(located.is_none()); + } + + #[test] + fn test_is_lockfile_stale_not_modified() { + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("Cargo.lock"); + std::fs::write(&lockfile_path, "version = 4").unwrap(); + + let mtime = std::fs::metadata(&lockfile_path) + .unwrap() + .modified() + .unwrap(); + let parser = CargoLockParser; + + assert!( + !parser.is_lockfile_stale(&lockfile_path, mtime), + "Lock file should not be stale when mtime matches" + ); + } + + #[test] + fn test_is_lockfile_stale_modified() { + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("Cargo.lock"); + std::fs::write(&lockfile_path, "version = 4").unwrap(); + + let old_time = std::time::UNIX_EPOCH; + let parser = CargoLockParser; + + assert!( + parser.is_lockfile_stale(&lockfile_path, old_time), + "Lock file should be stale when last_modified is old" + ); + } + + #[test] + fn test_is_lockfile_stale_deleted() { + let parser = CargoLockParser; + let non_existent = std::path::Path::new("/nonexistent/Cargo.lock"); + + assert!( + parser.is_lockfile_stale(non_existent, std::time::SystemTime::now()), + "Non-existent lock file should be considered stale" + ); + } + + #[test] + fn test_is_lockfile_stale_future_time() { + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("Cargo.lock"); + std::fs::write(&lockfile_path, "version = 4").unwrap(); + + // Use a time far in the future + let future_time = std::time::SystemTime::now() + std::time::Duration::from_secs(86400); // +1 day + let parser = CargoLockParser; + + assert!( + !parser.is_lockfile_stale(&lockfile_path, future_time), + "Lock file should not be stale when last_modified is in the future" + ); + } +} diff --git a/crates/deps-core/Cargo.toml b/crates/deps-core/Cargo.toml index 2cdf48e2..a491b902 100644 --- a/crates/deps-core/Cargo.toml +++ b/crates/deps-core/Cargo.toml @@ -17,7 +17,7 @@ semver = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } -tokio = { workspace = true, features = ["sync", "time"] } +tokio = { workspace = true, features = ["sync", "time", "fs"] } tower-lsp = { workspace = true } tracing = { workspace = true } diff --git a/crates/deps-core/src/handler.rs b/crates/deps-core/src/handler.rs index f9e746c1..1994e43d 100644 --- a/crates/deps-core/src/handler.rs +++ b/crates/deps-core/src/handler.rs @@ -192,6 +192,23 @@ pub trait EcosystemHandler: Send + Sync + Sized { fn parse_version_req( version_req: &str, ) -> Option<::VersionReq>; + + /// Get lock file provider for this ecosystem. + /// + /// Returns `None` if the ecosystem doesn't support lock files. + /// Default implementation returns `None`. + /// + /// # Examples + /// + /// ```ignore + /// // Override in handler implementation: + /// fn lockfile_provider(&self) -> Option> { + /// Some(Arc::new(MyLockParser)) + /// } + /// ``` + fn lockfile_provider(&self) -> Option> { + None + } } /// Configuration for inlay hint display. @@ -244,6 +261,7 @@ pub trait YankedChecker { /// * `handler` - Ecosystem-specific handler instance /// * `dependencies` - List of dependencies to process /// * `cached_versions` - Previously cached version information +/// * `resolved_versions` - Resolved versions from lock file /// * `config` - Display configuration /// /// # Returns @@ -253,6 +271,7 @@ pub async fn generate_inlay_hints( handler: &H, dependencies: &[H::UnifiedDep], cached_versions: &HashMap, + resolved_versions: &HashMap, config: &InlayHintsConfig, ) -> Vec where @@ -262,7 +281,6 @@ where let mut cached_deps = Vec::with_capacity(dependencies.len()); let mut fetch_deps = Vec::with_capacity(dependencies.len()); - // Separate deps into cached and needs-fetch for dep in dependencies { let Some(typed_dep) = H::extract_dependency(dep) else { continue; @@ -289,13 +307,6 @@ where } } - tracing::debug!( - "inlay hints: {} cached, {} to fetch", - cached_deps.len(), - fetch_deps.len() - ); - - // Fetch missing versions in parallel let registry = handler.registry().clone(); let futures: Vec<_> = fetch_deps .into_iter() @@ -312,12 +323,16 @@ where let mut hints = Vec::new(); - // Process cached deps for (name, version_req, version_range, latest_version, is_yanked) in cached_deps { if is_yanked { continue; } - let is_latest = H::is_version_latest(&version_req, &latest_version); + // Use resolved version from lock file if available, otherwise fall back to requirement + let version_to_compare = resolved_versions + .get(&name) + .map(String::as_str) + .unwrap_or(&version_req); + let is_latest = H::is_version_latest(version_to_compare, &latest_version); hints.push(create_hint::( &name, version_range, @@ -327,7 +342,6 @@ where )); } - // Process fetched deps for (name, version_req, version_range, result) in fetch_results { let Ok(versions): std::result::Result::Version>, _> = result @@ -340,10 +354,16 @@ where .iter() .find(|v: &&::Version| !v.is_yanked()) else { + tracing::warn!("No non-yanked versions found for '{}'", name); continue; }; - let is_latest = H::is_version_latest(&version_req, latest.version_string()); + // Use resolved version from lock file if available, otherwise fall back to requirement + let version_to_compare = resolved_versions + .get(&name) + .map(String::as_str) + .unwrap_or(&version_req); + let is_latest = H::is_version_latest(version_to_compare, latest.version_string()); hints.push(create_hint::( &name, version_range, @@ -356,9 +376,6 @@ where hints } -/// Generic hint creation. -/// -/// Uses ecosystem-specific URL and display name from the handler trait. #[inline] fn create_hint( name: &str, @@ -413,9 +430,16 @@ fn create_hint( /// # Type Parameters /// /// * `H` - Ecosystem handler type +/// +/// # Arguments +/// +/// * `handler` - Ecosystem handler instance +/// * `dep` - Dependency to generate hover for +/// * `resolved_version` - Optional resolved version from lock file (preferred over manifest version) pub async fn generate_hover( handler: &H, dep: &H::UnifiedDep, + resolved_version: Option<&str>, ) -> Option where H: EcosystemHandler, @@ -431,8 +455,8 @@ where let url = H::package_url(typed_dep.name()); let mut markdown = format!("# [{}]({})\n\n", typed_dep.name(), url); - if let Some(current) = typed_dep.version_requirement() { - markdown.push_str(&format!("**Current**: `{}`\n\n", current)); + if let Some(version) = resolved_version.or(typed_dep.version_requirement()) { + markdown.push_str(&format!("**Current**: `{}`\n\n", version)); } if latest.is_yanked() { @@ -454,7 +478,6 @@ where )); } - // Features (if supported by ecosystem) let features = latest.features(); if !features.is_empty() { markdown.push_str("\n**Features**:\n"); @@ -529,7 +552,6 @@ where CodeAction, CodeActionKind, CodeActionOrCommand, TextEdit, WorkspaceEdit, }; - // Extract dependencies overlapping with selected range let mut deps_to_check = Vec::new(); for dep in dependencies { let Some(typed_dep) = H::extract_dependency(dep) else { @@ -552,7 +574,6 @@ where return vec![]; } - // Fetch versions in parallel let registry = handler.registry().clone(); let futures: Vec<_> = deps_to_check .iter() @@ -569,7 +590,6 @@ where let results = join_all(futures).await; - // Generate actions let mut actions = Vec::new(); for (name, dep, version_range, versions_result) in results { let Ok(versions) = versions_result else { @@ -577,7 +597,6 @@ where continue; }; - // Offer up to MAX_CODE_ACTION_VERSIONS non-deprecated versions for (i, version) in versions .iter() .filter(|v| !H::is_deprecated(v)) @@ -617,7 +636,6 @@ where actions } -/// Helper: Check if two ranges overlap. fn ranges_overlap(a: Range, b: Range) -> bool { !(a.end.line < b.start.line || (a.end.line == b.start.line && a.end.character < b.start.character) @@ -656,7 +674,6 @@ where { use tower_lsp::lsp_types::{Diagnostic, DiagnosticSeverity}; - // Extract typed dependencies let mut deps_to_check = Vec::new(); for dep in dependencies { let Some(typed_dep) = H::extract_dependency(dep) else { @@ -669,7 +686,6 @@ where return vec![]; } - // Fetch versions in parallel let registry = handler.registry().clone(); let futures: Vec<_> = deps_to_check .iter() @@ -685,13 +701,11 @@ where let version_results = join_all(futures).await; - // Generate diagnostics let mut diagnostics = Vec::new(); for (i, dep) in deps_to_check.iter().enumerate() { let (name, version_result) = &version_results[i]; - // Check for unknown package let versions = match version_result { Ok(v) => v, Err(_) => { @@ -706,11 +720,9 @@ where } }; - // Check version requirement if present if let Some(version_req) = dep.version_requirement() && let Some(version_range) = dep.version_range() { - // Parse version requirement let Some(parsed_version_req) = H::parse_version_req(version_req) else { diagnostics.push(Diagnostic { range: version_range, @@ -722,7 +734,6 @@ where continue; }; - // Get matching version (skip if registry call fails) let matching = handler .registry() .get_latest_matching(name, &parsed_version_req) @@ -730,7 +741,6 @@ where .ok() .flatten(); - // Check for yanked/deprecated version if let Some(current) = &matching && H::is_deprecated(current) { @@ -743,7 +753,6 @@ where }); } - // Check for outdated version let latest = versions.iter().find(|v| !H::is_deprecated(v)); if let (Some(latest), Some(current)) = (latest, &matching) && latest.version_string() != current.version_string() @@ -1023,7 +1032,15 @@ mod tests { ); let config = InlayHintsConfig::default(); - let hints = generate_inlay_hints(&handler, &deps, &cached_versions, &config).await; + let resolved_versions: HashMap = HashMap::new(); + let hints = generate_inlay_hints( + &handler, + &deps, + &cached_versions, + &resolved_versions, + &config, + ) + .await; assert_eq!(hints.len(), 1); assert_eq!(hints[0].position.line, 0); @@ -1053,7 +1070,15 @@ mod tests { let cached_versions: HashMap = HashMap::new(); let config = InlayHintsConfig::default(); - let hints = generate_inlay_hints(&handler, &deps, &cached_versions, &config).await; + let resolved_versions: HashMap = HashMap::new(); + let hints = generate_inlay_hints( + &handler, + &deps, + &cached_versions, + &resolved_versions, + &config, + ) + .await; assert_eq!(hints.len(), 1); } @@ -1090,7 +1115,15 @@ mod tests { ); let config = InlayHintsConfig::default(); - let hints = generate_inlay_hints(&handler, &deps, &cached_versions, &config).await; + let resolved_versions: HashMap = HashMap::new(); + let hints = generate_inlay_hints( + &handler, + &deps, + &cached_versions, + &resolved_versions, + &config, + ) + .await; assert_eq!(hints.len(), 0); } @@ -1109,7 +1142,15 @@ mod tests { let cached_versions: HashMap = HashMap::new(); let config = InlayHintsConfig::default(); - let hints = generate_inlay_hints(&handler, &deps, &cached_versions, &config).await; + let resolved_versions: HashMap = HashMap::new(); + let hints = generate_inlay_hints( + &handler, + &deps, + &cached_versions, + &resolved_versions, + &config, + ) + .await; assert_eq!(hints.len(), 0); } @@ -1137,7 +1178,15 @@ mod tests { let cached_versions: HashMap = HashMap::new(); let config = InlayHintsConfig::default(); - let hints = generate_inlay_hints(&handler, &deps, &cached_versions, &config).await; + let resolved_versions: HashMap = HashMap::new(); + let hints = generate_inlay_hints( + &handler, + &deps, + &cached_versions, + &resolved_versions, + &config, + ) + .await; assert_eq!(hints.len(), 0); } @@ -1238,7 +1287,7 @@ mod tests { }, }; - let hover = generate_hover(&handler, &dep).await; + let hover = generate_hover(&handler, &dep, None).await; assert!(hover.is_some()); let hover = hover.unwrap(); @@ -1266,7 +1315,7 @@ mod tests { name_range: Range::default(), }; - let hover = generate_hover(&handler, &dep).await; + let hover = generate_hover(&handler, &dep, None).await; assert!(hover.is_some()); let hover = hover.unwrap(); @@ -1291,7 +1340,7 @@ mod tests { name_range: Range::default(), }; - let hover = generate_hover(&handler, &dep).await; + let hover = generate_hover(&handler, &dep, None).await; assert!(hover.is_none()); } @@ -1307,7 +1356,7 @@ mod tests { name_range: Range::default(), }; - let hover = generate_hover(&handler, &dep).await; + let hover = generate_hover(&handler, &dep, None).await; assert!(hover.is_some()); let hover = hover.unwrap(); @@ -1319,6 +1368,42 @@ mod tests { } } + #[tokio::test] + async fn test_generate_hover_with_resolved_version() { + let cache = Arc::new(HttpCache::new()); + let handler = MockHandler::new(cache); + + let dep = MockDependency { + name: "serde".to_string(), + version_req: Some("1.0".to_string()), // Manifest has short version + version_range: Some(Range::default()), + name_range: Range { + start: Position { + line: 0, + character: 0, + }, + end: Position { + line: 0, + character: 5, + }, + }, + }; + + // Pass resolved version from lock file (full version) + let hover = generate_hover(&handler, &dep, Some("1.0.195")).await; + + assert!(hover.is_some()); + let hover = hover.unwrap(); + + if let tower_lsp::lsp_types::HoverContents::Markup(content) = hover.contents { + // Should show the resolved version (1.0.195) not manifest version (1.0) + assert!(content.value.contains("**Current**: `1.0.195`")); + assert!(!content.value.contains("**Current**: `1.0`")); + } else { + panic!("Expected Markup content"); + } + } + #[tokio::test] async fn test_generate_code_actions_empty_when_up_to_date() { use tower_lsp::lsp_types::Url; diff --git a/crates/deps-core/src/lib.rs b/crates/deps-core/src/lib.rs index bc034be0..938b03e1 100644 --- a/crates/deps-core/src/lib.rs +++ b/crates/deps-core/src/lib.rs @@ -91,6 +91,7 @@ pub mod cache; pub mod error; pub mod handler; +pub mod lockfile; pub mod macros; pub mod parser; pub mod registry; @@ -103,6 +104,7 @@ pub use handler::{ DiagnosticsConfig, EcosystemHandler, InlayHintsConfig, VersionStringGetter, YankedChecker, generate_code_actions, generate_diagnostics, generate_hover, generate_inlay_hints, }; +pub use lockfile::{LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource}; pub use parser::{DependencyInfo, DependencySource, ManifestParser, ParseResultInfo}; pub use registry::{PackageMetadata, PackageRegistry, VersionInfo}; pub use version_matcher::{ diff --git a/crates/deps-core/src/lockfile.rs b/crates/deps-core/src/lockfile.rs new file mode 100644 index 00000000..3cb51151 --- /dev/null +++ b/crates/deps-core/src/lockfile.rs @@ -0,0 +1,512 @@ +//! Lock file parsing abstractions. +//! +//! Provides generic types and traits for parsing lock files across different +//! package ecosystems (Cargo.lock, package-lock.json, poetry.lock, etc.). +//! +//! Lock files contain resolved dependency versions, allowing instant display +//! without network requests to registries. + +use crate::error::Result; +use async_trait::async_trait; +use dashmap::DashMap; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::time::{Instant, SystemTime}; +use tower_lsp::lsp_types::Url; + +/// Resolved package information from a lock file. +/// +/// Contains the exact version and source information for a dependency +/// as resolved by the package manager. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedPackage { + /// Package name + pub name: String, + /// Resolved version (exact version from lock file) + pub version: String, + /// Source information (registry URL, git commit, path) + pub source: ResolvedSource, + /// Dependencies of this package (for dependency tree analysis) + pub dependencies: Vec, +} + +/// Source of a resolved dependency. +/// +/// Indicates where the package was downloaded from or how it was resolved. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ResolvedSource { + /// From a registry with optional checksum + Registry { + /// Registry URL + url: String, + /// Checksum/integrity hash + checksum: String, + }, + /// From git with commit hash + Git { + /// Git repository URL + url: String, + /// Commit SHA or tag + rev: String, + }, + /// From local file system + Path { + /// Relative or absolute path + path: String, + }, +} + +/// Collection of resolved packages from a lock file. +/// +/// Provides efficient lookup of resolved versions by package name. +/// +/// # Examples +/// +/// ``` +/// use deps_core::lockfile::{ResolvedPackages, ResolvedPackage, ResolvedSource}; +/// +/// let mut packages = ResolvedPackages::new(); +/// packages.insert(ResolvedPackage { +/// name: "serde".into(), +/// version: "1.0.195".into(), +/// source: ResolvedSource::Registry { +/// url: "https://github.com/rust-lang/crates.io-index".into(), +/// checksum: "abc123".into(), +/// }, +/// dependencies: vec!["serde_derive".into()], +/// }); +/// +/// assert_eq!(packages.get_version("serde"), Some("1.0.195")); +/// assert_eq!(packages.len(), 1); +/// ``` +#[derive(Debug, Default, Clone)] +pub struct ResolvedPackages { + /// Map from package name to resolved package info + packages: HashMap, +} + +impl ResolvedPackages { + /// Creates a new empty collection. + pub fn new() -> Self { + Self { + packages: HashMap::new(), + } + } + + /// Inserts a resolved package. + /// + /// If a package with the same name already exists, it is replaced. + pub fn insert(&mut self, package: ResolvedPackage) { + self.packages.insert(package.name.clone(), package); + } + + /// Gets a resolved package by name. + /// + /// Returns `None` if the package is not in the lock file. + pub fn get(&self, name: &str) -> Option<&ResolvedPackage> { + self.packages.get(name) + } + + /// Gets the resolved version string for a package. + /// + /// Returns `None` if the package is not in the lock file. + /// + /// This is a convenience method equivalent to `get(name).map(|p| p.version.as_str())`. + pub fn get_version(&self, name: &str) -> Option<&str> { + self.packages.get(name).map(|p| p.version.as_str()) + } + + /// Returns the number of resolved packages. + pub fn len(&self) -> usize { + self.packages.len() + } + + /// Returns true if there are no resolved packages. + pub fn is_empty(&self) -> bool { + self.packages.is_empty() + } + + /// Returns an iterator over package names and their resolved info. + pub fn iter(&self) -> impl Iterator { + self.packages.iter() + } + + /// Converts into a HashMap for easier integration. + pub fn into_map(self) -> HashMap { + self.packages + } +} + +/// Lock file provider trait for ecosystem-specific implementations. +/// +/// Implementations parse lock files for a specific package ecosystem +/// (Cargo.lock, package-lock.json, etc.) and extract resolved versions. +/// +/// # Examples +/// +/// ```no_run +/// use deps_core::lockfile::{LockFileProvider, ResolvedPackages}; +/// use async_trait::async_trait; +/// use std::path::{Path, PathBuf}; +/// use tower_lsp::lsp_types::Url; +/// +/// struct MyLockParser; +/// +/// #[async_trait] +/// impl LockFileProvider for MyLockParser { +/// fn locate_lockfile(&self, manifest_uri: &Url) -> Option { +/// let manifest_path = manifest_uri.to_file_path().ok()?; +/// let lock_path = manifest_path.with_file_name("my.lock"); +/// lock_path.exists().then_some(lock_path) +/// } +/// +/// async fn parse_lockfile(&self, lockfile_path: &Path) -> deps_core::error::Result { +/// // Parse lock file format and extract packages +/// Ok(ResolvedPackages::new()) +/// } +/// } +/// ``` +#[async_trait] +pub trait LockFileProvider: Send + Sync { + /// Locates the lock file for a given manifest URI. + /// + /// Returns `None` if: + /// - Lock file doesn't exist + /// - Manifest path cannot be determined from URI + /// - Workspace root search fails + /// + /// # Arguments + /// + /// * `manifest_uri` - URI of the manifest file (Cargo.toml, package.json, etc.) + /// + /// # Returns + /// + /// Path to lock file if found + fn locate_lockfile(&self, manifest_uri: &Url) -> Option; + + /// Parses a lock file and extracts resolved packages. + /// + /// # Arguments + /// + /// * `lockfile_path` - Path to the lock file + /// + /// # Returns + /// + /// ResolvedPackages on success, error if parse fails + /// + /// # Errors + /// + /// Returns an error if: + /// - File cannot be read + /// - File format is invalid + /// - Required fields are missing + async fn parse_lockfile(&self, lockfile_path: &Path) -> Result; + + /// Checks if lock file has been modified since last parse. + /// + /// Used for cache invalidation. Default implementation compares + /// file modification time. + /// + /// # Arguments + /// + /// * `lockfile_path` - Path to the lock file + /// * `last_modified` - Last known modification time + /// + /// # Returns + /// + /// `true` if file has been modified or cannot be stat'd, `false` otherwise + fn is_lockfile_stale(&self, lockfile_path: &Path, last_modified: SystemTime) -> bool { + if let Ok(metadata) = std::fs::metadata(lockfile_path) + && let Ok(mtime) = metadata.modified() + { + return mtime > last_modified; + } + true + } +} + +/// Cached lock file entry with staleness detection. +struct CachedLockFile { + packages: ResolvedPackages, + modified_at: SystemTime, + #[allow(dead_code)] + parsed_at: Instant, +} + +/// Cache for parsed lock files with automatic staleness detection. +/// +/// Caches parsed lock file contents and checks file modification time +/// to avoid re-parsing unchanged files. Thread-safe for concurrent access. +/// +/// # Examples +/// +/// ```no_run +/// use deps_core::lockfile::LockFileCache; +/// use std::path::Path; +/// +/// # async fn example() -> deps_core::error::Result<()> { +/// let cache = LockFileCache::new(); +/// // First call parses the file +/// // Second call returns cached result if file hasn't changed +/// # Ok(()) +/// # } +/// ``` +pub struct LockFileCache { + entries: DashMap, +} + +impl LockFileCache { + /// Creates a new empty lock file cache. + pub fn new() -> Self { + Self { + entries: DashMap::new(), + } + } + + /// Gets parsed packages from cache or parses the lock file. + /// + /// Checks file modification time to detect changes. If the file + /// has been modified since last parse, re-parses it. Otherwise, + /// returns the cached result. + /// + /// # Arguments + /// + /// * `provider` - Lock file provider implementation + /// * `lockfile_path` - Path to the lock file + /// + /// # Returns + /// + /// Resolved packages on success + /// + /// # Errors + /// + /// Returns error if file cannot be read or parsed + pub async fn get_or_parse( + &self, + provider: &dyn LockFileProvider, + lockfile_path: &Path, + ) -> Result { + // Check cache first + if let Some(cached) = self.entries.get(lockfile_path) + && let Ok(metadata) = tokio::fs::metadata(lockfile_path).await + && let Ok(mtime) = metadata.modified() + && mtime <= cached.modified_at + { + tracing::debug!("Lock file cache hit: {}", lockfile_path.display()); + return Ok(cached.packages.clone()); + } + + // Cache miss - parse and store + tracing::debug!("Lock file cache miss: {}", lockfile_path.display()); + let packages = provider.parse_lockfile(lockfile_path).await?; + + let metadata = tokio::fs::metadata(lockfile_path).await?; + let modified_at = metadata.modified()?; + + self.entries.insert( + lockfile_path.to_path_buf(), + CachedLockFile { + packages: packages.clone(), + modified_at, + parsed_at: Instant::now(), + }, + ); + + Ok(packages) + } + + /// Invalidates cached entry for a lock file. + /// + /// Forces next access to re-parse the file. Use when you know + /// the file has changed but modification time might not reflect it. + pub fn invalidate(&self, lockfile_path: &Path) { + self.entries.remove(lockfile_path); + } + + /// Returns the number of cached lock files. + pub fn len(&self) -> usize { + self.entries.len() + } + + /// Returns true if the cache is empty. + pub fn is_empty(&self) -> bool { + self.entries.is_empty() + } +} + +impl Default for LockFileCache { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_resolved_packages_new() { + let packages = ResolvedPackages::new(); + assert!(packages.is_empty()); + assert_eq!(packages.len(), 0); + } + + #[test] + fn test_resolved_packages_insert_and_get() { + let mut packages = ResolvedPackages::new(); + + let pkg = ResolvedPackage { + name: "serde".into(), + version: "1.0.195".into(), + source: ResolvedSource::Registry { + url: "https://github.com/rust-lang/crates.io-index".into(), + checksum: "abc123".into(), + }, + dependencies: vec!["serde_derive".into()], + }; + + packages.insert(pkg); + + assert_eq!(packages.len(), 1); + assert!(!packages.is_empty()); + assert_eq!(packages.get_version("serde"), Some("1.0.195")); + + let retrieved = packages.get("serde"); + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().name, "serde"); + assert_eq!(retrieved.unwrap().dependencies.len(), 1); + } + + #[test] + fn test_resolved_packages_get_nonexistent() { + let packages = ResolvedPackages::new(); + assert_eq!(packages.get("nonexistent"), None); + assert_eq!(packages.get_version("nonexistent"), None); + } + + #[test] + fn test_resolved_packages_replace() { + let mut packages = ResolvedPackages::new(); + + packages.insert(ResolvedPackage { + name: "serde".into(), + version: "1.0.0".into(), + source: ResolvedSource::Registry { + url: "test".into(), + checksum: "old".into(), + }, + dependencies: vec![], + }); + + packages.insert(ResolvedPackage { + name: "serde".into(), + version: "1.0.195".into(), + source: ResolvedSource::Registry { + url: "test".into(), + checksum: "new".into(), + }, + dependencies: vec![], + }); + + assert_eq!(packages.len(), 1); + assert_eq!(packages.get_version("serde"), Some("1.0.195")); + } + + #[test] + fn test_resolved_source_equality() { + let source1 = ResolvedSource::Registry { + url: "https://test.com".into(), + checksum: "abc".into(), + }; + let source2 = ResolvedSource::Registry { + url: "https://test.com".into(), + checksum: "abc".into(), + }; + let source3 = ResolvedSource::Git { + url: "https://github.com/test".into(), + rev: "abc123".into(), + }; + + assert_eq!(source1, source2); + assert_ne!(source1, source3); + } + + #[test] + fn test_resolved_packages_iter() { + let mut packages = ResolvedPackages::new(); + + packages.insert(ResolvedPackage { + name: "serde".into(), + version: "1.0.0".into(), + source: ResolvedSource::Registry { + url: "test".into(), + checksum: "a".into(), + }, + dependencies: vec![], + }); + + packages.insert(ResolvedPackage { + name: "tokio".into(), + version: "1.0.0".into(), + source: ResolvedSource::Registry { + url: "test".into(), + checksum: "b".into(), + }, + dependencies: vec![], + }); + + let count = packages.iter().count(); + assert_eq!(count, 2); + + let names: Vec<_> = packages.iter().map(|(name, _)| name.as_str()).collect(); + assert!(names.contains(&"serde")); + assert!(names.contains(&"tokio")); + } + + #[test] + fn test_resolved_packages_into_map() { + let mut packages = ResolvedPackages::new(); + + packages.insert(ResolvedPackage { + name: "serde".into(), + version: "1.0.0".into(), + source: ResolvedSource::Registry { + url: "test".into(), + checksum: "a".into(), + }, + dependencies: vec![], + }); + + let map = packages.into_map(); + assert_eq!(map.len(), 1); + assert!(map.contains_key("serde")); + } + + #[test] + fn test_lockfile_cache_new() { + let cache = LockFileCache::new(); + assert!(cache.is_empty()); + assert_eq!(cache.len(), 0); + } + + #[test] + fn test_lockfile_cache_invalidate() { + let cache = LockFileCache::new(); + let test_path = PathBuf::from("/test/Cargo.lock"); + + cache.entries.insert( + test_path.clone(), + CachedLockFile { + packages: ResolvedPackages::new(), + modified_at: SystemTime::now(), + parsed_at: Instant::now(), + }, + ); + + assert_eq!(cache.len(), 1); + + cache.invalidate(&test_path); + assert_eq!(cache.len(), 0); + assert!(cache.is_empty()); + } +} diff --git a/crates/deps-lsp/src/document.rs b/crates/deps-lsp/src/document.rs index d1d6e872..bf9de3a2 100644 --- a/crates/deps-lsp/src/document.rs +++ b/crates/deps-lsp/src/document.rs @@ -1,6 +1,7 @@ use dashmap::DashMap; use deps_cargo::{CargoVersion, ParsedDependency}; use deps_core::HttpCache; +use deps_core::lockfile::LockFileCache; use deps_npm::{NpmDependency, NpmVersion}; use deps_pypi::{PypiDependency, PypiVersion}; use std::collections::HashMap; @@ -220,8 +221,10 @@ pub struct DocumentState { pub content: String, /// Parsed dependencies with positions pub dependencies: Vec, - /// Cached version information + /// Cached latest version information from registry pub versions: HashMap, + /// Resolved versions from lock file + pub resolved_versions: HashMap, /// Last successful parse time pub parsed_at: Instant, } @@ -241,23 +244,28 @@ impl DocumentState { content, dependencies, versions: HashMap::new(), + resolved_versions: HashMap::new(), parsed_at: Instant::now(), } } - /// Updates the cached version information for dependencies. - /// - /// This is called after fetching version data from the registry. + /// Updates the cached latest version information for dependencies. pub fn update_versions(&mut self, versions: HashMap) { self.versions = versions; } + + /// Updates the resolved versions from lock file. + pub fn update_resolved_versions(&mut self, versions: HashMap) { + self.resolved_versions = versions; + } } /// Global LSP server state. /// -/// Manages all open documents, HTTP cache, and background tasks for the server. -/// This state is shared across all LSP handlers via `Arc` and uses concurrent -/// data structures (`DashMap`, `RwLock`) for thread-safe access. +/// Manages all open documents, HTTP cache, lock file cache, and background +/// tasks for the server. This state is shared across all LSP handlers via +/// `Arc` and uses concurrent data structures (`DashMap`, `RwLock`) for +/// thread-safe access. /// /// # Examples /// @@ -273,6 +281,8 @@ pub struct ServerState { pub documents: DashMap, /// HTTP cache for registry requests pub cache: Arc, + /// Lock file cache for parsed lock files + pub lockfile_cache: Arc, /// Background task handles tasks: tokio::sync::RwLock>>, } @@ -283,6 +293,7 @@ impl ServerState { Self { documents: DashMap::new(), cache: Arc::new(HttpCache::new()), + lockfile_cache: Arc::new(LockFileCache::new()), tasks: tokio::sync::RwLock::new(HashMap::new()), } } diff --git a/crates/deps-lsp/src/document_lifecycle.rs b/crates/deps-lsp/src/document_lifecycle.rs index 67137180..41c27bde 100644 --- a/crates/deps-lsp/src/document_lifecycle.rs +++ b/crates/deps-lsp/src/document_lifecycle.rs @@ -70,25 +70,68 @@ where Fn(::Version) -> UnifiedVersion + Send + 'static + Clone, ShouldFetch: Fn(&H::Dependency) -> bool + Send + 'static + Clone, { - // Step 1: Parse manifest let parse_result = parse_fn(&content, &uri)?; let dependencies: Vec = parse_result.into_iter().collect(); - // Step 2: Wrap dependencies let unified_deps: Vec = dependencies.into_iter().map(wrap_dep_fn).collect(); + let lockfile_versions = { + let cache = Arc::clone(&state.cache); + let handler = H::new(cache); + + if let Some(provider) = handler.lockfile_provider() { + if let Some(lockfile_path) = provider.locate_lockfile(&uri) { + match state + .lockfile_cache + .get_or_parse(provider.as_ref(), &lockfile_path) + .await + { + Ok(resolved) => { + tracing::info!( + "Loaded lock file: {} packages from {}", + resolved.len(), + lockfile_path.display() + ); + Some(resolved) + } + Err(e) => { + tracing::warn!("Failed to parse lock file: {}", e); + None + } + } + } else { + tracing::debug!("No lock file found for {}", uri); + None + } + } else { + None + } + }; + + let mut doc_state = DocumentState::new(ecosystem, content, unified_deps); + if let Some(resolved) = lockfile_versions { + let resolved_versions: HashMap = resolved + .iter() + .map(|(name, pkg)| (name.clone(), pkg.version.clone())) + .collect(); + + tracing::info!( + "Populated {} resolved versions from lock file: {:?}", + resolved_versions.len(), + resolved_versions.keys().take(10).collect::>() + ); + doc_state.update_resolved_versions(resolved_versions); + } else { + tracing::warn!("No lock file versions found for {}", uri); + } - // Step 3: Create document state - let doc_state = DocumentState::new(ecosystem, content, unified_deps); state.update_document(uri.clone(), doc_state); - // Step 4: Spawn background version fetch task let uri_clone = uri.clone(); let task = tokio::spawn(async move { let cache = Arc::clone(&state.cache); let handler = H::new(cache); let registry = handler.registry().clone(); - // Collect dependencies to fetch (avoid holding doc lock during fetch) let deps_to_fetch: Vec<_> = { let doc = match state.get_document(&uri_clone) { Some(d) => d, @@ -107,7 +150,6 @@ where .collect() }; - // Parallel fetch all versions let futures: Vec<_> = deps_to_fetch .into_iter() .map(|name| { @@ -124,12 +166,10 @@ where let results = join_all(futures).await; let versions: HashMap<_, _> = results.into_iter().flatten().collect(); - // Update document with fetched versions if let Some(mut doc) = state.documents.get_mut(&uri_clone) { doc.update_versions(versions); } - // Publish diagnostics let config_read = config.read().await; let diags = diagnostics::handle_diagnostics( Arc::clone(&state), @@ -138,7 +178,14 @@ where ) .await; - client.publish_diagnostics(uri_clone, diags, None).await; + client + .publish_diagnostics(uri_clone.clone(), diags, None) + .await; + + // Refresh inlay hints after versions are fetched + if let Err(e) = client.inlay_hint_refresh().await { + tracing::debug!("inlay_hint_refresh not supported: {:?}", e); + } }); Ok(task) @@ -187,24 +234,28 @@ where ParseResult: IntoIterator, WrapDep: Fn(H::Dependency) -> UnifiedDependency, { - // Step 1: Parse manifest let parse_result = parse_fn(&content, &uri)?; let dependencies: Vec = parse_result.into_iter().collect(); - // Step 2: Wrap dependencies let unified_deps: Vec = dependencies.into_iter().map(wrap_dep_fn).collect(); - // Step 3: Update document state - let doc_state = DocumentState::new(ecosystem, content, unified_deps); + // Preserve existing resolved_versions from lock file when updating + let existing_resolved = state + .get_document(&uri) + .map(|doc| doc.resolved_versions.clone()) + .unwrap_or_default(); + + let mut doc_state = DocumentState::new(ecosystem, content, unified_deps); + if !existing_resolved.is_empty() { + doc_state.update_resolved_versions(existing_resolved); + } state.update_document(uri.clone(), doc_state); - // Step 4: Spawn debounced diagnostics update task let uri_clone = uri.clone(); let task = tokio::spawn(async move { - // Debounce: wait 100ms for rapid edits to settle + // Debounce: wait for rapid edits to settle tokio::time::sleep(std::time::Duration::from_millis(100)).await; - // Publish diagnostics let config_read = config.read().await; let diags = diagnostics::handle_diagnostics( Arc::clone(&state), @@ -217,7 +268,6 @@ where .publish_diagnostics(uri_clone.clone(), diags, None) .await; - // Request inlay hints refresh if let Err(e) = client.inlay_hint_refresh().await { tracing::debug!("inlay_hint_refresh not supported: {:?}", e); } diff --git a/crates/deps-lsp/src/handlers/cargo_handler_impl.rs b/crates/deps-lsp/src/handlers/cargo_handler_impl.rs index 452305af..9b43c741 100644 --- a/crates/deps-lsp/src/handlers/cargo_handler_impl.rs +++ b/crates/deps-lsp/src/handlers/cargo_handler_impl.rs @@ -5,8 +5,10 @@ use crate::document::UnifiedDependency; use async_trait::async_trait; -use deps_cargo::{CratesIoRegistry, ParsedDependency, crate_url}; -use deps_core::{EcosystemHandler, HttpCache, SemverMatcher, VersionRequirementMatcher}; +use deps_cargo::{CargoLockParser, CratesIoRegistry, ParsedDependency, crate_url}; +use deps_core::{ + EcosystemHandler, HttpCache, LockFileProvider, SemverMatcher, VersionRequirementMatcher, +}; use std::sync::Arc; /// Cargo ecosystem handler with UnifiedDependency support. @@ -67,4 +69,8 @@ impl EcosystemHandler for CargoHandlerImpl { fn parse_version_req(version_req: &str) -> Option { version_req.parse().ok() } + + fn lockfile_provider(&self) -> Option> { + Some(Arc::new(CargoLockParser)) + } } diff --git a/crates/deps-lsp/src/handlers/hover.rs b/crates/deps-lsp/src/handlers/hover.rs index 40829825..5156905e 100644 --- a/crates/deps-lsp/src/handlers/hover.rs +++ b/crates/deps-lsp/src/handlers/hover.rs @@ -49,25 +49,37 @@ pub async fn handle_hover(state: Arc, params: HoverParams) -> Optio let ecosystem = doc.ecosystem; let dep = dep.clone(); + let dep_name = dep.name(); + tracing::debug!( + "Hover: looking up '{}' in resolved_versions ({} entries): {:?}", + dep_name, + doc.resolved_versions.len(), + doc.resolved_versions.keys().take(5).collect::>() + ); + let resolved_version = doc.resolved_versions.get(dep_name).cloned(); + tracing::debug!( + "Hover: resolved_version for '{}' = {:?}", + dep_name, + resolved_version + ); drop(doc); match ecosystem { Ecosystem::Cargo => { let handler = CargoHandlerImpl::new(Arc::clone(&state.cache)); - generate_hover(&handler, &dep).await + generate_hover(&handler, &dep, resolved_version.as_deref()).await } Ecosystem::Npm => { let handler = NpmHandlerImpl::new(Arc::clone(&state.cache)); - generate_hover(&handler, &dep).await + generate_hover(&handler, &dep, resolved_version.as_deref()).await } Ecosystem::Pypi => { let handler = PyPiHandlerImpl::new(Arc::clone(&state.cache)); - generate_hover(&handler, &dep).await + generate_hover(&handler, &dep, resolved_version.as_deref()).await } } } -/// Checks if a position is within a range. fn position_in_range(pos: Position, range: Range) -> bool { (pos.line > range.start.line || (pos.line == range.start.line && pos.character >= range.start.character)) diff --git a/crates/deps-lsp/src/handlers/inlay_hints.rs b/crates/deps-lsp/src/handlers/inlay_hints.rs index ba07087a..2a50610e 100644 --- a/crates/deps-lsp/src/handlers/inlay_hints.rs +++ b/crates/deps-lsp/src/handlers/inlay_hints.rs @@ -31,26 +31,12 @@ pub async fn handle_inlay_hints( ) -> Vec { let uri = ¶ms.text_document.uri; - tracing::info!( - "inlay_hint request: uri={}, range={}:{}-{}:{}", - uri, - params.range.start.line, - params.range.start.character, - params.range.end.line, - params.range.end.character - ); - if !config.enabled { - tracing::debug!("inlay hints disabled in config"); return vec![]; } - let doc = match state.get_document(uri) { - Some(d) => d, - None => { - tracing::warn!("Document not found for inlay hints: {}", uri); - return vec![]; - } + let Some(doc) = state.get_document(uri) else { + return vec![]; }; let ecosystem = doc.ecosystem; @@ -64,16 +50,8 @@ pub async fn handle_inlay_hints( .cloned() .collect(); - // Get cached versions before dropping doc let cached_versions = doc.versions.clone(); - - tracing::info!( - "inlay hints: found {} dependencies to fetch (total {} in doc, {} cached)", - deps_to_fetch.len(), - doc.dependencies.len(), - cached_versions.len() - ); - + let resolved_versions = doc.resolved_versions.clone(); drop(doc); let core_config = deps_core::InlayHintsConfig { @@ -82,23 +60,41 @@ pub async fn handle_inlay_hints( needs_update_text: config.needs_update_text.clone(), }; - let hints = match ecosystem { + match ecosystem { Ecosystem::Cargo => { let handler = CargoHandlerImpl::new(Arc::clone(&state.cache)); - generate_inlay_hints(&handler, &deps_to_fetch, &cached_versions, &core_config).await + generate_inlay_hints( + &handler, + &deps_to_fetch, + &cached_versions, + &resolved_versions, + &core_config, + ) + .await } Ecosystem::Npm => { let handler = NpmHandlerImpl::new(Arc::clone(&state.cache)); - generate_inlay_hints(&handler, &deps_to_fetch, &cached_versions, &core_config).await + generate_inlay_hints( + &handler, + &deps_to_fetch, + &cached_versions, + &resolved_versions, + &core_config, + ) + .await } Ecosystem::Pypi => { let handler = PyPiHandlerImpl::new(Arc::clone(&state.cache)); - generate_inlay_hints(&handler, &deps_to_fetch, &cached_versions, &core_config).await + generate_inlay_hints( + &handler, + &deps_to_fetch, + &cached_versions, + &resolved_versions, + &core_config, + ) + .await } - }; - - tracing::info!("returning {} inlay hints", hints.len()); - hints + } } #[cfg(test)] diff --git a/crates/deps-lsp/src/handlers/npm_handler_impl.rs b/crates/deps-lsp/src/handlers/npm_handler_impl.rs index 801118d4..cf3c22d1 100644 --- a/crates/deps-lsp/src/handlers/npm_handler_impl.rs +++ b/crates/deps-lsp/src/handlers/npm_handler_impl.rs @@ -5,8 +5,10 @@ use crate::document::UnifiedDependency; use async_trait::async_trait; -use deps_core::{EcosystemHandler, HttpCache, SemverMatcher, VersionRequirementMatcher}; -use deps_npm::{NpmDependency, NpmRegistry, package_url}; +use deps_core::{ + EcosystemHandler, HttpCache, LockFileProvider, SemverMatcher, VersionRequirementMatcher, +}; +use deps_npm::{NpmDependency, NpmLockParser, NpmRegistry, package_url}; use std::sync::Arc; /// npm ecosystem handler with UnifiedDependency support. @@ -67,4 +69,8 @@ impl EcosystemHandler for NpmHandlerImpl { fn parse_version_req(version_req: &str) -> Option { version_req.parse().ok() } + + fn lockfile_provider(&self) -> Option> { + Some(Arc::new(NpmLockParser)) + } } diff --git a/crates/deps-lsp/src/handlers/pypi_handler_impl.rs b/crates/deps-lsp/src/handlers/pypi_handler_impl.rs index 814e70b8..7f00eb0e 100644 --- a/crates/deps-lsp/src/handlers/pypi_handler_impl.rs +++ b/crates/deps-lsp/src/handlers/pypi_handler_impl.rs @@ -5,8 +5,10 @@ use crate::document::UnifiedDependency; use async_trait::async_trait; -use deps_core::{EcosystemHandler, HttpCache, Pep440Matcher, VersionRequirementMatcher}; -use deps_pypi::{PypiDependency, PypiRegistry}; +use deps_core::{ + EcosystemHandler, HttpCache, LockFileProvider, Pep440Matcher, VersionRequirementMatcher, +}; +use deps_pypi::{PypiDependency, PypiLockParser, PypiRegistry}; use std::sync::Arc; /// PyPI ecosystem handler with UnifiedDependency support. @@ -79,4 +81,8 @@ impl EcosystemHandler for PyPiHandlerImpl { fn parse_version_req(version_req: &str) -> Option { Some(version_req.to_string()) } + + fn lockfile_provider(&self) -> Option> { + Some(Arc::new(PypiLockParser)) + } } diff --git a/crates/deps-lsp/src/main.rs b/crates/deps-lsp/src/main.rs index f8a0bd7b..d7236607 100644 --- a/crates/deps-lsp/src/main.rs +++ b/crates/deps-lsp/src/main.rs @@ -1,26 +1,19 @@ use deps_lsp::server::Backend; use tower_lsp::{LspService, Server}; -use tracing_subscriber::{EnvFilter, fmt}; +use tracing_subscriber::EnvFilter; #[tokio::main] async fn main() { - // Initialize tracing with environment filter - // Write to stderr to avoid interfering with JSON-RPC on stdout - fmt() + tracing_subscriber::fmt() .with_env_filter( EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), ) - .with_writer(std::io::stderr) .init(); - tracing::info!("starting deps-lsp server"); - let stdin = tokio::io::stdin(); let stdout = tokio::io::stdout(); let (service, socket) = LspService::new(Backend::new); Server::new(stdin, stdout, socket).serve(service).await; - - tracing::info!("deps-lsp server stopped"); } diff --git a/crates/deps-npm/Cargo.toml b/crates/deps-npm/Cargo.toml index 4e9b6ca4..7af4469e 100644 --- a/crates/deps-npm/Cargo.toml +++ b/crates/deps-npm/Cargo.toml @@ -14,6 +14,7 @@ async-trait = { workspace = true } node-semver = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +tokio = { workspace = true } tower-lsp = { workspace = true } tracing = { workspace = true } urlencoding = { workspace = true } @@ -21,6 +22,7 @@ urlencoding = { workspace = true } [dev-dependencies] criterion = { workspace = true, features = ["html_reports"] } insta = { workspace = true, features = ["json"] } +tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [[bench]] diff --git a/crates/deps-npm/src/lib.rs b/crates/deps-npm/src/lib.rs index 9030672c..bb638602 100644 --- a/crates/deps-npm/src/lib.rs +++ b/crates/deps-npm/src/lib.rs @@ -3,10 +3,12 @@ //! This module provides package.json parsing and npm registry integration //! for JavaScript/TypeScript projects. +pub mod lockfile; pub mod parser; pub mod registry; pub mod types; +pub use lockfile::NpmLockParser; pub use parser::{NpmParseResult, parse_package_json}; pub use registry::{NpmRegistry, package_url}; pub use types::{NpmDependency, NpmDependencySection, NpmPackage, NpmVersion}; diff --git a/crates/deps-npm/src/lockfile.rs b/crates/deps-npm/src/lockfile.rs new file mode 100644 index 00000000..45df8fd1 --- /dev/null +++ b/crates/deps-npm/src/lockfile.rs @@ -0,0 +1,661 @@ +//! package-lock.json file parsing. +//! +//! Parses package-lock.json files (versions 2 and 3) to extract resolved dependency +//! versions. Supports npm workspaces and proper path resolution. +//! +//! # package-lock.json Format +//! +//! package-lock.json uses JSON format with a "packages" object: +//! +//! ```json +//! { +//! "name": "my-project", +//! "lockfileVersion": 3, +//! "packages": { +//! "": { +//! "name": "my-project", +//! "dependencies": { "express": "^4.18.0" } +//! }, +//! "node_modules/express": { +//! "version": "4.18.2", +//! "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", +//! "integrity": "sha512-..." +//! } +//! } +//! } +//! ``` + +use async_trait::async_trait; +use deps_core::error::{DepsError, Result}; +use deps_core::lockfile::{LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource}; +use serde::Deserialize; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use tower_lsp::lsp_types::Url; + +/// package-lock.json file parser. +/// +/// Implements lock file parsing for npm package manager. +/// Supports both project-level and workspace-level lock files. +/// +/// # Lock File Location +/// +/// The parser searches for package-lock.json in the following order: +/// 1. Same directory as package.json +/// 2. Parent directories (up to 5 levels) for workspace root +/// +/// # Examples +/// +/// ```no_run +/// use deps_npm::lockfile::NpmLockParser; +/// use deps_core::lockfile::LockFileProvider; +/// use tower_lsp::lsp_types::Url; +/// +/// # async fn example() -> deps_core::error::Result<()> { +/// let parser = NpmLockParser; +/// let manifest_uri = Url::parse("file:///path/to/package.json").unwrap(); +/// +/// if let Some(lockfile_path) = parser.locate_lockfile(&manifest_uri) { +/// let resolved = parser.parse_lockfile(&lockfile_path).await?; +/// println!("Found {} resolved packages", resolved.len()); +/// } +/// # Ok(()) +/// # } +/// ``` +pub struct NpmLockParser; + +impl NpmLockParser { + /// Maximum depth to search for workspace root lock file. + const MAX_WORKSPACE_DEPTH: usize = 5; +} + +/// package-lock.json structure (partial, only fields we need). +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PackageLockJson { + /// Packages object with resolved dependencies + #[serde(default)] + packages: HashMap, +} + +/// Individual package entry in the "packages" object. +#[derive(Debug, Deserialize)] +struct PackageEntry { + /// Package version + version: Option, + + /// Registry URL where package was downloaded from + resolved: Option, + + /// Integrity hash (sha512-... format) + integrity: Option, + + /// True for local packages + link: Option, + + /// Dependencies of this package (optional, for dependency tree) + #[serde(default)] + dependencies: HashMap, +} + +#[async_trait] +impl LockFileProvider for NpmLockParser { + fn locate_lockfile(&self, manifest_uri: &Url) -> Option { + let manifest_path = manifest_uri.to_file_path().ok()?; + + // Try same directory as manifest + let lock_path = manifest_path.with_file_name("package-lock.json"); + if lock_path.exists() { + tracing::debug!("Found package-lock.json at: {}", lock_path.display()); + return Some(lock_path); + } + + // Search up the directory tree for workspace root + let mut current_dir = manifest_path.parent()?; + + for depth in 0..Self::MAX_WORKSPACE_DEPTH { + let workspace_lock = current_dir.join("package-lock.json"); + if workspace_lock.exists() { + tracing::debug!( + "Found workspace package-lock.json at depth {}: {}", + depth + 1, + workspace_lock.display() + ); + return Some(workspace_lock); + } + + current_dir = current_dir.parent()?; + } + + tracing::debug!("No package-lock.json found for: {}", manifest_uri); + None + } + + async fn parse_lockfile(&self, lockfile_path: &Path) -> Result { + tracing::debug!("Parsing package-lock.json: {}", lockfile_path.display()); + + let content = tokio::fs::read_to_string(lockfile_path) + .await + .map_err(|e| DepsError::ParseError { + file_type: format!("package-lock.json at {}", lockfile_path.display()), + source: Box::new(e), + })?; + + let lock_data: PackageLockJson = + serde_json::from_str(&content).map_err(|e| DepsError::ParseError { + file_type: "package-lock.json".into(), + source: Box::new(e), + })?; + + let mut packages = ResolvedPackages::new(); + + for (key, entry) in lock_data.packages { + // Skip root package (empty key) + if key.is_empty() { + continue; + } + + // Extract package name from key (e.g., "node_modules/express" -> "express") + let name = extract_package_name(&key); + + // Version is required for actual dependencies + let Some(ref version) = entry.version else { + tracing::debug!("Skipping package '{}' with no version", name); + continue; + }; + + // Parse source based on link, resolved, and integrity fields + let source = parse_npm_source(&entry); + + // Extract dependency names + let dependencies: Vec = entry.dependencies.keys().cloned().collect(); + + packages.insert(ResolvedPackage { + name: name.to_string(), + version: version.clone(), + source, + dependencies, + }); + } + + tracing::info!( + "Parsed package-lock.json: {} packages from {}", + packages.len(), + lockfile_path.display() + ); + + Ok(packages) + } +} + +/// Extracts package name from lockfile key. +/// +/// # Examples +/// +/// - `"node_modules/express"` → `"express"` +/// - `"node_modules/@babel/core"` → `"@babel/core"` +/// - `"node_modules/express/node_modules/debug"` → `"debug"` +fn extract_package_name(key: &str) -> &str { + // Find the last occurrence of "node_modules/" + key.rsplit("node_modules/").next().unwrap_or(key) +} + +/// Parses npm source information into ResolvedSource. +/// +/// # Source Detection +/// +/// - `link: true` → Path (local package) +/// - `resolved` URL with `integrity` → Registry +/// - `resolved` git URL → Git +/// - No `resolved` → Path (workspace dependency) +fn parse_npm_source(entry: &PackageEntry) -> ResolvedSource { + // Local packages (link: true) + if entry.link == Some(true) { + return ResolvedSource::Path { + path: String::new(), + }; + } + + // Parse resolved URL + if let Some(resolved_url) = &entry.resolved { + // Git sources (various formats) + if resolved_url.starts_with("git+") + || resolved_url.starts_with("git://") + || resolved_url.contains("github.com") + && (resolved_url.contains(".git") || resolved_url.contains("/tarball/")) + { + return parse_git_source(resolved_url); + } + + // Registry source with integrity + if let Some(integrity) = &entry.integrity { + return ResolvedSource::Registry { + url: resolved_url.clone(), + checksum: integrity.clone(), + }; + } + + // Registry without integrity (shouldn't happen in v2+, but handle it) + return ResolvedSource::Registry { + url: resolved_url.clone(), + checksum: String::new(), + }; + } + + // No resolved URL means local/workspace dependency + ResolvedSource::Path { + path: String::new(), + } +} + +/// Parses Git source URL and extracts commit hash. +/// +/// # Git URL Formats +/// +/// - `git+https://github.com/user/repo.git#abc123` → rev: abc123 +/// - `https://github.com/user/repo/tarball/abc123` → rev: abc123 +/// - `git://github.com/user/repo.git#v1.0.0` → rev: v1.0.0 +fn parse_git_source(url: &str) -> ResolvedSource { + // Try to extract commit hash from URL + let (clean_url, rev) = if let Some((base, hash)) = url.split_once('#') { + (base.to_string(), hash.to_string()) + } else if url.contains("/tarball/") { + // GitHub tarball URL: .../tarball/commitish + if let Some(idx) = url.rfind("/tarball/") { + let base = &url[..idx]; + let hash = &url[idx + 9..]; // len("/tarball/") = 9 + (base.to_string(), hash.to_string()) + } else { + (url.to_string(), String::new()) + } + } else { + (url.to_string(), String::new()) + }; + + // Remove git+ prefix if present + let clean_url = clean_url + .strip_prefix("git+") + .unwrap_or(&clean_url) + .to_string(); + + ResolvedSource::Git { + url: clean_url, + rev, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_package_name_simple() { + assert_eq!(extract_package_name("node_modules/express"), "express"); + } + + #[test] + fn test_extract_package_name_scoped() { + assert_eq!( + extract_package_name("node_modules/@babel/core"), + "@babel/core" + ); + } + + #[test] + fn test_extract_package_name_nested() { + assert_eq!( + extract_package_name("node_modules/express/node_modules/debug"), + "debug" + ); + } + + #[test] + fn test_parse_npm_source_registry() { + let entry = PackageEntry { + version: Some("4.18.2".into()), + resolved: Some("https://registry.npmjs.org/express/-/express-4.18.2.tgz".into()), + integrity: Some("sha512-abc123".into()), + link: None, + dependencies: HashMap::new(), + }; + + let source = parse_npm_source(&entry); + + match source { + ResolvedSource::Registry { url, checksum } => { + assert_eq!( + url, + "https://registry.npmjs.org/express/-/express-4.18.2.tgz" + ); + assert_eq!(checksum, "sha512-abc123"); + } + _ => panic!("Expected Registry source"), + } + } + + #[test] + fn test_parse_npm_source_link() { + let entry = PackageEntry { + version: Some("1.0.0".into()), + resolved: None, + integrity: None, + link: Some(true), + dependencies: HashMap::new(), + }; + + let source = parse_npm_source(&entry); + + match source { + ResolvedSource::Path { .. } => {} + _ => panic!("Expected Path source"), + } + } + + #[test] + fn test_parse_git_source_with_hash() { + let source = parse_git_source("git+https://github.com/user/repo.git#abc123"); + + match source { + ResolvedSource::Git { url, rev } => { + assert_eq!(url, "https://github.com/user/repo.git"); + assert_eq!(rev, "abc123"); + } + _ => panic!("Expected Git source"), + } + } + + #[test] + fn test_parse_git_source_tarball() { + let source = parse_git_source("https://github.com/user/repo/tarball/abc123"); + + match source { + ResolvedSource::Git { url, rev } => { + assert_eq!(url, "https://github.com/user/repo"); + assert_eq!(rev, "abc123"); + } + _ => panic!("Expected Git source"), + } + } + + #[test] + fn test_parse_git_source_no_hash() { + let source = parse_git_source("git+https://github.com/user/repo.git"); + + match source { + ResolvedSource::Git { url, rev } => { + assert_eq!(url, "https://github.com/user/repo.git"); + assert!(rev.is_empty()); + } + _ => panic!("Expected Git source"), + } + } + + #[tokio::test] + async fn test_parse_simple_package_lock() { + let lockfile_content = r#"{ + "name": "my-project", + "lockfileVersion": 3, + "packages": { + "": { + "name": "my-project", + "dependencies": { + "express": "^4.18.0" + } + }, + "node_modules/express": { + "version": "4.18.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz", + "integrity": "sha512-abc123", + "dependencies": { + "body-parser": "1.20.1" + } + }, + "node_modules/body-parser": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz", + "integrity": "sha512-def456" + } + } +}"#; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("package-lock.json"); + tokio::fs::write(&lockfile_path, lockfile_content) + .await + .unwrap(); + + let parser = NpmLockParser; + let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap(); + + assert_eq!(resolved.len(), 2); + assert_eq!(resolved.get_version("express"), Some("4.18.2")); + assert_eq!(resolved.get_version("body-parser"), Some("1.20.1")); + + let express_pkg = resolved.get("express").unwrap(); + assert_eq!(express_pkg.dependencies.len(), 1); + assert_eq!(express_pkg.dependencies[0], "body-parser"); + } + + #[tokio::test] + async fn test_parse_package_lock_with_git() { + let lockfile_content = r#"{ + "lockfileVersion": 3, + "packages": { + "": { + "dependencies": { + "my-git-dep": "github:user/repo#abc123" + } + }, + "node_modules/my-git-dep": { + "version": "0.1.0", + "resolved": "git+https://github.com/user/repo.git#abc123" + } + } +}"#; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("package-lock.json"); + tokio::fs::write(&lockfile_path, lockfile_content) + .await + .unwrap(); + + let parser = NpmLockParser; + let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap(); + + assert_eq!(resolved.len(), 1); + let pkg = resolved.get("my-git-dep").unwrap(); + assert_eq!(pkg.version, "0.1.0"); + + match &pkg.source { + ResolvedSource::Git { url, rev } => { + assert_eq!(url, "https://github.com/user/repo.git"); + assert_eq!(rev, "abc123"); + } + _ => panic!("Expected Git source"), + } + } + + #[tokio::test] + async fn test_parse_package_lock_with_local() { + let lockfile_content = r#"{ + "lockfileVersion": 3, + "packages": { + "": { + "dependencies": { + "my-local": "file:../my-local" + } + }, + "node_modules/my-local": { + "version": "1.0.0", + "link": true + } + } +}"#; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("package-lock.json"); + tokio::fs::write(&lockfile_path, lockfile_content) + .await + .unwrap(); + + let parser = NpmLockParser; + let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap(); + + assert_eq!(resolved.len(), 1); + let pkg = resolved.get("my-local").unwrap(); + + match &pkg.source { + ResolvedSource::Path { .. } => {} + _ => panic!("Expected Path source for local package"), + } + } + + #[tokio::test] + async fn test_parse_empty_package_lock() { + let lockfile_content = r#"{ + "lockfileVersion": 3, + "packages": { + "": { + "name": "empty-project" + } + } +}"#; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("package-lock.json"); + tokio::fs::write(&lockfile_path, lockfile_content) + .await + .unwrap(); + + let parser = NpmLockParser; + let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap(); + + assert_eq!(resolved.len(), 0); + assert!(resolved.is_empty()); + } + + #[tokio::test] + async fn test_parse_malformed_package_lock() { + let lockfile_content = "not valid json {{{"; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("package-lock.json"); + tokio::fs::write(&lockfile_path, lockfile_content) + .await + .unwrap(); + + let parser = NpmLockParser; + let result = parser.parse_lockfile(&lockfile_path).await; + + assert!(result.is_err()); + } + + #[test] + fn test_locate_lockfile_same_directory() { + let temp_dir = tempfile::tempdir().unwrap(); + let manifest_path = temp_dir.path().join("package.json"); + let lock_path = temp_dir.path().join("package-lock.json"); + + std::fs::write(&manifest_path, r#"{"name": "test"}"#).unwrap(); + std::fs::write(&lock_path, r#"{"lockfileVersion": 3}"#).unwrap(); + + let manifest_uri = Url::from_file_path(&manifest_path).unwrap(); + let parser = NpmLockParser; + + let located = parser.locate_lockfile(&manifest_uri); + assert!(located.is_some()); + assert_eq!(located.unwrap(), lock_path); + } + + #[test] + fn test_locate_lockfile_workspace_root() { + let temp_dir = tempfile::tempdir().unwrap(); + let workspace_lock = temp_dir.path().join("package-lock.json"); + let member_dir = temp_dir.path().join("packages").join("member"); + std::fs::create_dir_all(&member_dir).unwrap(); + let member_manifest = member_dir.join("package.json"); + + std::fs::write(&workspace_lock, r#"{"lockfileVersion": 3}"#).unwrap(); + std::fs::write(&member_manifest, r#"{"name": "member"}"#).unwrap(); + + let manifest_uri = Url::from_file_path(&member_manifest).unwrap(); + let parser = NpmLockParser; + + let located = parser.locate_lockfile(&manifest_uri); + assert!(located.is_some()); + assert_eq!(located.unwrap(), workspace_lock); + } + + #[test] + fn test_locate_lockfile_not_found() { + let temp_dir = tempfile::tempdir().unwrap(); + let manifest_path = temp_dir.path().join("package.json"); + std::fs::write(&manifest_path, r#"{"name": "test"}"#).unwrap(); + + let manifest_uri = Url::from_file_path(&manifest_path).unwrap(); + let parser = NpmLockParser; + + let located = parser.locate_lockfile(&manifest_uri); + assert!(located.is_none()); + } + + #[test] + fn test_is_lockfile_stale_not_modified() { + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("package-lock.json"); + std::fs::write(&lockfile_path, r#"{"lockfileVersion": 3}"#).unwrap(); + + let mtime = std::fs::metadata(&lockfile_path) + .unwrap() + .modified() + .unwrap(); + let parser = NpmLockParser; + + assert!( + !parser.is_lockfile_stale(&lockfile_path, mtime), + "Lock file should not be stale when mtime matches" + ); + } + + #[test] + fn test_is_lockfile_stale_modified() { + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("package-lock.json"); + std::fs::write(&lockfile_path, r#"{"lockfileVersion": 3}"#).unwrap(); + + let old_time = std::time::UNIX_EPOCH; + let parser = NpmLockParser; + + assert!( + parser.is_lockfile_stale(&lockfile_path, old_time), + "Lock file should be stale when last_modified is old" + ); + } + + #[test] + fn test_is_lockfile_stale_deleted() { + let parser = NpmLockParser; + let non_existent = std::path::Path::new("/nonexistent/package-lock.json"); + + assert!( + parser.is_lockfile_stale(non_existent, std::time::SystemTime::now()), + "Non-existent lock file should be considered stale" + ); + } + + #[test] + fn test_is_lockfile_stale_future_time() { + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("package-lock.json"); + std::fs::write(&lockfile_path, r#"{"lockfileVersion": 3}"#).unwrap(); + + // Use a time far in the future + let future_time = std::time::SystemTime::now() + std::time::Duration::from_secs(86400); // +1 day + let parser = NpmLockParser; + + assert!( + !parser.is_lockfile_stale(&lockfile_path, future_time), + "Lock file should not be stale when last_modified is in the future" + ); + } +} diff --git a/crates/deps-pypi/Cargo.toml b/crates/deps-pypi/Cargo.toml index 14b9d10d..4b692121 100644 --- a/crates/deps-pypi/Cargo.toml +++ b/crates/deps-pypi/Cargo.toml @@ -16,6 +16,7 @@ pep508_rs = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } thiserror = { workspace = true } +tokio = { workspace = true } tower-lsp = { workspace = true } toml_edit = { workspace = true } tracing = { workspace = true } @@ -24,6 +25,7 @@ tracing = { workspace = true } criterion = { workspace = true, features = ["html_reports"] } insta = { workspace = true, features = ["json"] } mockito = { workspace = true } +tempfile = { workspace = true } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } [[bench]] diff --git a/crates/deps-pypi/src/lib.rs b/crates/deps-pypi/src/lib.rs index 0ba87349..08f66332 100644 --- a/crates/deps-pypi/src/lib.rs +++ b/crates/deps-pypi/src/lib.rs @@ -96,12 +96,14 @@ //! ``` pub mod error; +pub mod lockfile; pub mod parser; pub mod registry; pub mod types; // Re-export commonly used types pub use error::{PypiError, Result}; +pub use lockfile::PypiLockParser; pub use parser::PypiParser; pub use registry::PypiRegistry; pub use types::{ diff --git a/crates/deps-pypi/src/lockfile.rs b/crates/deps-pypi/src/lockfile.rs new file mode 100644 index 00000000..78d8b03a --- /dev/null +++ b/crates/deps-pypi/src/lockfile.rs @@ -0,0 +1,703 @@ +//! Poetry/uv lock file parsing. +//! +//! Parses `poetry.lock` and `uv.lock` files to extract resolved dependency +//! versions. Both formats use TOML with `[[package]]` sections. +//! +//! # Lock File Formats +//! +//! ## Poetry +//! +//! ```toml +//! # This file is automatically generated by poetry. +//! [[package]] +//! name = "requests" +//! version = "2.31.0" +//! description = "Python HTTP for Humans." +//! +//! [package.dependencies] +//! certifi = ">=2017.4.17" +//! charset-normalizer = ">=2,<4" +//! +//! [metadata] +//! lock-version = "2.0" +//! python-versions = "^3.9" +//! ``` +//! +//! ## uv +//! +//! ```toml +//! version = 1 +//! +//! [[package]] +//! name = "requests" +//! version = "2.31.0" +//! source = { registry = "https://pypi.org/simple" } +//! dependencies = [ +//! { name = "certifi" }, +//! { name = "charset-normalizer" }, +//! ] +//! ``` + +use async_trait::async_trait; +use deps_core::error::{DepsError, Result}; +use deps_core::lockfile::{LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource}; +use std::path::{Path, PathBuf}; +use toml_edit::DocumentMut; +use tower_lsp::lsp_types::Url; + +/// PyPI lock file parser. +/// +/// Implements lock file parsing for Python package managers (Poetry, uv). +/// Supports both `poetry.lock` and `uv.lock` formats. +/// +/// # Lock File Location +/// +/// The parser searches for lock files in the following order: +/// 1. `poetry.lock` in the same directory as `pyproject.toml` +/// 2. `uv.lock` in the same directory as `pyproject.toml` +/// +/// Poetry takes priority because it's more established. +/// +/// # Examples +/// +/// ```no_run +/// use deps_pypi::lockfile::PypiLockParser; +/// use deps_core::lockfile::LockFileProvider; +/// use tower_lsp::lsp_types::Url; +/// +/// # async fn example() -> deps_core::error::Result<()> { +/// let parser = PypiLockParser; +/// let manifest_uri = Url::parse("file:///path/to/pyproject.toml").unwrap(); +/// +/// if let Some(lockfile_path) = parser.locate_lockfile(&manifest_uri) { +/// let resolved = parser.parse_lockfile(&lockfile_path).await?; +/// println!("Found {} resolved packages", resolved.len()); +/// } +/// # Ok(()) +/// # } +/// ``` +pub struct PypiLockParser; + +#[async_trait] +impl LockFileProvider for PypiLockParser { + fn locate_lockfile(&self, manifest_uri: &Url) -> Option { + let manifest_path = manifest_uri.to_file_path().ok()?; + let dir = manifest_path.parent()?; + + // Try poetry.lock first (more established) + let poetry_lock = dir.join("poetry.lock"); + if poetry_lock.exists() { + tracing::debug!("Found poetry.lock at: {}", poetry_lock.display()); + return Some(poetry_lock); + } + + // Fall back to uv.lock + let uv_lock = dir.join("uv.lock"); + if uv_lock.exists() { + tracing::debug!("Found uv.lock at: {}", uv_lock.display()); + return Some(uv_lock); + } + + tracing::debug!("No lock file found for: {}", manifest_uri); + None + } + + async fn parse_lockfile(&self, lockfile_path: &Path) -> Result { + tracing::debug!("Parsing lock file: {}", lockfile_path.display()); + + let content = tokio::fs::read_to_string(lockfile_path) + .await + .map_err(|e| DepsError::ParseError { + file_type: format!("lock file at {}", lockfile_path.display()), + source: Box::new(e), + })?; + + let doc: DocumentMut = content.parse().map_err(|e| DepsError::ParseError { + file_type: "Python lock file".into(), + source: Box::new(e), + })?; + + let mut packages = ResolvedPackages::new(); + + let Some(package_array) = doc + .get("package") + .and_then(|v: &toml_edit::Item| v.as_array_of_tables()) + else { + tracing::warn!("Lock file missing [[package]] array of tables"); + return Ok(packages); + }; + + for table in package_array.iter() { + // Extract required fields + let Some(name) = table.get("name").and_then(|v: &toml_edit::Item| v.as_str()) else { + tracing::warn!("Package missing name field"); + continue; + }; + + let Some(version) = table + .get("version") + .and_then(|v: &toml_edit::Item| v.as_str()) + else { + tracing::warn!("Package '{}' missing version field", name); + continue; + }; + + // Parse source (format varies between poetry and uv) + let source = parse_pypi_source(table); + + // Parse dependencies (format varies between poetry and uv) + let dependencies = parse_pypi_dependencies(table); + + packages.insert(ResolvedPackage { + name: name.to_string(), + version: version.to_string(), + source, + dependencies, + }); + } + + tracing::info!( + "Parsed lock file: {} packages from {}", + packages.len(), + lockfile_path.display() + ); + + Ok(packages) + } +} + +/// Parses source information from package table. +/// +/// Handles both Poetry and uv source formats: +/// +/// # Poetry Format +/// +/// - No `source` field → PyPI registry (default) +/// - `source.type = "git"` with `source.url` and `source.resolved_reference` +/// - `source.type = "directory"` or `source.type = "file"` with `source.url` +/// +/// # uv Format +/// +/// - `source.registry = "https://pypi.org/simple"` → Registry +/// - `source.git = "https://github.com/..."` → Git +/// - `source.path = "..."` → Path +fn parse_pypi_source(table: &toml_edit::Table) -> ResolvedSource { + let Some(source_item) = table.get("source") else { + // No source field = PyPI registry (poetry default) + return ResolvedSource::Registry { + url: "https://pypi.org/simple".to_string(), + checksum: String::new(), + }; + }; + + // Handle inline table format (uv style) + if let Some(source_table) = source_item.as_inline_table() { + // uv: source = { registry = "https://pypi.org/simple" } + if let Some(registry) = source_table.get("registry").and_then(|v| v.as_str()) { + return ResolvedSource::Registry { + url: registry.to_string(), + checksum: String::new(), + }; + } + + // uv: source = { git = "https://github.com/..." } + if let Some(git_url) = source_table.get("git").and_then(|v| v.as_str()) { + let rev = source_table + .get("rev") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + return ResolvedSource::Git { + url: git_url.to_string(), + rev, + }; + } + + // uv: source = { path = "..." } + if let Some(path) = source_table.get("path").and_then(|v| v.as_str()) { + return ResolvedSource::Path { + path: path.to_string(), + }; + } + } + + // Handle table format (poetry style) + if let Some(source_table) = source_item.as_table() { + // poetry: [package.source] type = "git" + if let Some(source_type) = source_table.get("type").and_then(|v| v.as_str()) { + match source_type { + "git" => { + let url = source_table + .get("url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let rev = source_table + .get("resolved_reference") + .or_else(|| source_table.get("reference")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + return ResolvedSource::Git { url, rev }; + } + "directory" | "file" => { + let path = source_table + .get("url") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + return ResolvedSource::Path { path }; + } + _ => {} + } + } + } + + // Default to PyPI registry + ResolvedSource::Registry { + url: "https://pypi.org/simple".to_string(), + checksum: String::new(), + } +} + +/// Parses dependencies from package table. +/// +/// Handles both Poetry and uv dependency formats: +/// +/// # Poetry Format +/// +/// ```toml +/// [package.dependencies] +/// certifi = ">=2017.4.17" +/// charset-normalizer = ">=2,<4" +/// ``` +/// +/// # uv Format +/// +/// ```toml +/// dependencies = [ +/// { name = "certifi" }, +/// { name = "charset-normalizer" }, +/// ] +/// ``` +fn parse_pypi_dependencies(table: &toml_edit::Table) -> Vec { + // Try uv format first (dependencies array) + if let Some(deps_value) = table.get("dependencies") + && let Some(deps_array) = deps_value.as_array() + { + return deps_array + .iter() + .filter_map(|item| { + // uv format: { name = "certifi" } + if let Some(dep_table) = item.as_inline_table() + && let Some(name) = dep_table.get("name").and_then(|v| v.as_str()) + { + return Some(name.to_string()); + } + + // Simple string format (fallback) + if let Some(s) = item.as_str() { + return Some(s.to_string()); + } + + None + }) + .collect(); + } + + // Try poetry format (package.dependencies table) + if let Some(deps_item) = table.get("dependencies") + && let Some(deps_table) = deps_item.as_table() + { + return deps_table + .iter() + .map(|(name, _)| name.to_string()) + .collect(); + } + + vec![] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_parse_simple_poetry_lock() { + let lockfile_content = r#" +# This file is automatically generated by poetry. +[[package]] +name = "requests" +version = "2.31.0" +description = "Python HTTP for Humans." + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" + +[[package]] +name = "certifi" +version = "2023.7.22" +description = "Python package for providing Mozilla's CA Bundle." + +[metadata] +lock-version = "2.0" +python-versions = "^3.9" +"#; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("poetry.lock"); + std::fs::write(&lockfile_path, lockfile_content).unwrap(); + + let parser = PypiLockParser; + let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap(); + + assert_eq!(resolved.len(), 2); + assert_eq!(resolved.get_version("requests"), Some("2.31.0")); + assert_eq!(resolved.get_version("certifi"), Some("2023.7.22")); + + let requests_pkg = resolved.get("requests").unwrap(); + assert_eq!(requests_pkg.dependencies.len(), 2); + assert!(requests_pkg.dependencies.contains(&"certifi".to_string())); + assert!( + requests_pkg + .dependencies + .contains(&"charset-normalizer".to_string()) + ); + + // Verify it's a registry source + match &requests_pkg.source { + ResolvedSource::Registry { url, .. } => { + assert_eq!(url, "https://pypi.org/simple"); + } + _ => panic!("Expected Registry source"), + } + } + + #[tokio::test] + async fn test_parse_uv_lock() { + let lockfile_content = r#" +version = 1 + +[[package]] +name = "requests" +version = "2.31.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, +] + +[[package]] +name = "certifi" +version = "2023.7.22" +source = { registry = "https://pypi.org/simple" } +"#; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("uv.lock"); + std::fs::write(&lockfile_path, lockfile_content).unwrap(); + + let parser = PypiLockParser; + let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap(); + + assert_eq!(resolved.len(), 2); + assert_eq!(resolved.get_version("requests"), Some("2.31.0")); + assert_eq!(resolved.get_version("certifi"), Some("2023.7.22")); + + let requests_pkg = resolved.get("requests").unwrap(); + assert_eq!(requests_pkg.dependencies.len(), 2); + assert!(requests_pkg.dependencies.contains(&"certifi".to_string())); + + match &requests_pkg.source { + ResolvedSource::Registry { url, .. } => { + assert_eq!(url, "https://pypi.org/simple"); + } + _ => panic!("Expected Registry source"), + } + } + + #[tokio::test] + async fn test_parse_poetry_lock_with_git() { + let lockfile_content = r#" +[[package]] +name = "my-git-dep" +version = "0.1.0" +description = "Git dependency" + +[package.source] +type = "git" +url = "https://github.com/user/repo" +resolved_reference = "abc123def456" +"#; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("poetry.lock"); + std::fs::write(&lockfile_path, lockfile_content).unwrap(); + + let parser = PypiLockParser; + let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap(); + + assert_eq!(resolved.len(), 1); + let pkg = resolved.get("my-git-dep").unwrap(); + assert_eq!(pkg.version, "0.1.0"); + + match &pkg.source { + ResolvedSource::Git { url, rev } => { + assert_eq!(url, "https://github.com/user/repo"); + assert_eq!(rev, "abc123def456"); + } + _ => panic!("Expected Git source"), + } + } + + #[tokio::test] + async fn test_parse_uv_lock_with_git() { + let lockfile_content = r#" +version = 1 + +[[package]] +name = "my-git-dep" +version = "0.1.0" +source = { git = "https://github.com/user/repo", rev = "abc123" } +"#; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("uv.lock"); + std::fs::write(&lockfile_path, lockfile_content).unwrap(); + + let parser = PypiLockParser; + let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap(); + + assert_eq!(resolved.len(), 1); + let pkg = resolved.get("my-git-dep").unwrap(); + + match &pkg.source { + ResolvedSource::Git { url, rev } => { + assert_eq!(url, "https://github.com/user/repo"); + assert_eq!(rev, "abc123"); + } + _ => panic!("Expected Git source"), + } + } + + #[tokio::test] + async fn test_parse_poetry_lock_with_path() { + let lockfile_content = r#" +[[package]] +name = "my-local-dep" +version = "0.1.0" + +[package.source] +type = "directory" +url = "../local-package" +"#; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("poetry.lock"); + std::fs::write(&lockfile_path, lockfile_content).unwrap(); + + let parser = PypiLockParser; + let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap(); + + assert_eq!(resolved.len(), 1); + let pkg = resolved.get("my-local-dep").unwrap(); + + match &pkg.source { + ResolvedSource::Path { path } => { + assert_eq!(path, "../local-package"); + } + _ => panic!("Expected Path source"), + } + } + + #[tokio::test] + async fn test_parse_uv_lock_with_path() { + let lockfile_content = r#" +version = 1 + +[[package]] +name = "my-local-dep" +version = "0.1.0" +source = { path = "../local-package" } +"#; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("uv.lock"); + std::fs::write(&lockfile_path, lockfile_content).unwrap(); + + let parser = PypiLockParser; + let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap(); + + assert_eq!(resolved.len(), 1); + let pkg = resolved.get("my-local-dep").unwrap(); + + match &pkg.source { + ResolvedSource::Path { path } => { + assert_eq!(path, "../local-package"); + } + _ => panic!("Expected Path source"), + } + } + + #[tokio::test] + async fn test_parse_empty_lock_file() { + let lockfile_content = r#" +version = 1 +"#; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("poetry.lock"); + std::fs::write(&lockfile_path, lockfile_content).unwrap(); + + let parser = PypiLockParser; + let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap(); + + assert_eq!(resolved.len(), 0); + assert!(resolved.is_empty()); + } + + #[tokio::test] + async fn test_parse_malformed_toml() { + let lockfile_content = "not valid toml {{{"; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("poetry.lock"); + std::fs::write(&lockfile_path, lockfile_content).unwrap(); + + let parser = PypiLockParser; + let result = parser.parse_lockfile(&lockfile_path).await; + + assert!(result.is_err()); + } + + #[test] + fn test_locate_lockfile_poetry_priority() { + let temp_dir = tempfile::tempdir().unwrap(); + let manifest_path = temp_dir.path().join("pyproject.toml"); + let poetry_lock = temp_dir.path().join("poetry.lock"); + let uv_lock = temp_dir.path().join("uv.lock"); + + std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap(); + std::fs::write(&poetry_lock, "# poetry.lock").unwrap(); + std::fs::write(&uv_lock, "# uv.lock").unwrap(); + + let manifest_uri = Url::from_file_path(&manifest_path).unwrap(); + let parser = PypiLockParser; + + let located = parser.locate_lockfile(&manifest_uri); + assert!(located.is_some()); + assert_eq!( + located.unwrap(), + poetry_lock, + "poetry.lock should take priority over uv.lock" + ); + } + + #[test] + fn test_locate_lockfile_uv_fallback() { + let temp_dir = tempfile::tempdir().unwrap(); + let manifest_path = temp_dir.path().join("pyproject.toml"); + let uv_lock = temp_dir.path().join("uv.lock"); + + std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap(); + std::fs::write(&uv_lock, "# uv.lock").unwrap(); + + let manifest_uri = Url::from_file_path(&manifest_path).unwrap(); + let parser = PypiLockParser; + + let located = parser.locate_lockfile(&manifest_uri); + assert!(located.is_some()); + assert_eq!(located.unwrap(), uv_lock); + } + + #[test] + fn test_locate_lockfile_not_found() { + let temp_dir = tempfile::tempdir().unwrap(); + let manifest_path = temp_dir.path().join("pyproject.toml"); + std::fs::write(&manifest_path, "[project]\nname = \"test\"").unwrap(); + + let manifest_uri = Url::from_file_path(&manifest_path).unwrap(); + let parser = PypiLockParser; + + let located = parser.locate_lockfile(&manifest_uri); + assert!(located.is_none()); + } + + #[tokio::test] + async fn test_parse_poetry_lock_missing_fields() { + let lockfile_content = r#" +[[package]] +name = "valid-package" +version = "1.0.0" + +[[package]] +# Missing name field +version = "2.0.0" + +[[package]] +name = "missing-version" +# Missing version field +"#; + + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("poetry.lock"); + std::fs::write(&lockfile_path, lockfile_content).unwrap(); + + let parser = PypiLockParser; + let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap(); + + // Should only parse valid package + assert_eq!(resolved.len(), 1); + assert_eq!(resolved.get_version("valid-package"), Some("1.0.0")); + assert!(resolved.get("missing-version").is_none()); + } + + #[test] + fn test_is_lockfile_stale_not_modified() { + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("poetry.lock"); + std::fs::write(&lockfile_path, "version = 1").unwrap(); + + let mtime = std::fs::metadata(&lockfile_path) + .unwrap() + .modified() + .unwrap(); + let parser = PypiLockParser; + + assert!( + !parser.is_lockfile_stale(&lockfile_path, mtime), + "Lock file should not be stale when mtime matches" + ); + } + + #[test] + fn test_is_lockfile_stale_modified() { + let temp_dir = tempfile::tempdir().unwrap(); + let lockfile_path = temp_dir.path().join("poetry.lock"); + std::fs::write(&lockfile_path, "version = 1").unwrap(); + + let old_time = std::time::UNIX_EPOCH; + let parser = PypiLockParser; + + assert!( + parser.is_lockfile_stale(&lockfile_path, old_time), + "Lock file should be stale when last_modified is old" + ); + } + + #[test] + fn test_is_lockfile_stale_deleted() { + let parser = PypiLockParser; + let non_existent = std::path::Path::new("/nonexistent/poetry.lock"); + + assert!( + parser.is_lockfile_stale(non_existent, std::time::SystemTime::now()), + "Non-existent lock file should be considered stale" + ); + } +} diff --git a/crates/deps-pypi/src/parser.rs b/crates/deps-pypi/src/parser.rs index 0b6de342..ff3c3da4 100644 --- a/crates/deps-pypi/src/parser.rs +++ b/crates/deps-pypi/src/parser.rs @@ -335,12 +335,18 @@ impl PypiParser { extras_joined.len() + 2 // +2 for [ and ] }; let start_offset = name.len() + extras_str_len; + + // Calculate original version length from requirement_str + // pep508 normalizes version specifiers (e.g., ">=1.7,<2.0" -> ">=1.7, <2.0") + // We need the original length for correct position tracking + let original_version_len = requirement_str.len() - start_offset; + let version_range = base_position.map(|pos| { Range::new( Position::new(pos.line, pos.character + start_offset as u32), Position::new( pos.line, - pos.character + start_offset as u32 + version_str.len() as u32, + pos.character + start_offset as u32 + original_version_len as u32, ), ) }); @@ -881,6 +887,51 @@ dev = ["pytest>=8.0", "mypy>=1.0"] assert_eq!(version_range.end.character, 32); } + #[test] + fn test_version_range_position_without_space() { + // Bug: pep508 normalizes ">=1.7,<2.0" to ">=1.7, <2.0" (adds space) + // Version range end must use original string length, not normalized + let content = r#"[dependency-groups] +dev = [ + "maturin>=1.7,<2.0", +] +"#; + // Line 0: [dependency-groups] + // Line 1: dev = [ + // Line 2: "maturin>=1.7,<2.0", + // ^ ^ ^ + // 5 12 22 (end of version, before closing quote) + + let parser = PypiParser::new(); + let result = parser.parse_content(content).unwrap(); + let maturin = &result.dependencies[0]; + + let version_range = maturin.version_range.unwrap(); + assert_eq!(version_range.start.line, 2); + assert_eq!(version_range.start.character, 12); // after "maturin" + assert_eq!(version_range.end.line, 2); + assert_eq!(version_range.end.character, 22); // ">=1.7,<2.0" = 10 chars + } + + #[test] + fn test_version_range_position_with_space() { + // With space in original - should also work correctly + let content = r#"[dependency-groups] +dev = [ + "maturin>=1.7, <2.0", +] +"#; + // ">=1.7, <2.0" = 11 chars, end at 12 + 11 = 23 + + let parser = PypiParser::new(); + let result = parser.parse_content(content).unwrap(); + let maturin = &result.dependencies[0]; + + let version_range = maturin.version_range.unwrap(); + assert_eq!(version_range.start.character, 12); + assert_eq!(version_range.end.character, 23); + } + #[test] fn test_position_tracking_with_extras() { let content = r#"[project] diff --git a/scripts/install-local.sh b/scripts/install-local.sh new file mode 100755 index 00000000..af5be0e0 --- /dev/null +++ b/scripts/install-local.sh @@ -0,0 +1,9 @@ +#!/bin/bash +# Install deps-lsp to ~/.local/bin for Zed dev extension testing + +set -e + +cargo build --release -p deps-lsp +cp target/release/deps-lsp ~/.local/bin/ +echo "✓ Installed deps-lsp to ~/.local/bin/" +echo " Restart Zed to use the new version"