deps_npm/
lockfile.rs

1//! package-lock.json file parsing.
2//!
3//! Parses package-lock.json files (versions 2 and 3) to extract resolved dependency
4//! versions. Supports npm workspaces and proper path resolution.
5//!
6//! # package-lock.json Format
7//!
8//! package-lock.json uses JSON format with a "packages" object:
9//!
10//! ```json
11//! {
12//!   "name": "my-project",
13//!   "lockfileVersion": 3,
14//!   "packages": {
15//!     "": {
16//!       "name": "my-project",
17//!       "dependencies": { "express": "^4.18.0" }
18//!     },
19//!     "node_modules/express": {
20//!       "version": "4.18.2",
21//!       "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
22//!       "integrity": "sha512-..."
23//!     }
24//!   }
25//! }
26//! ```
27
28use async_trait::async_trait;
29use deps_core::error::{DepsError, Result};
30use deps_core::lockfile::{
31    LockFileProvider, ResolvedPackage, ResolvedPackages, ResolvedSource,
32    locate_lockfile_for_manifest,
33};
34use serde::Deserialize;
35use std::collections::HashMap;
36use std::path::{Path, PathBuf};
37use tower_lsp_server::ls_types::Uri;
38
39/// package-lock.json file parser.
40///
41/// Implements lock file parsing for npm package manager.
42/// Supports both project-level and workspace-level lock files.
43///
44/// # Lock File Location
45///
46/// The parser searches for package-lock.json in the following order:
47/// 1. Same directory as package.json
48/// 2. Parent directories (up to 5 levels) for workspace root
49///
50/// # Examples
51///
52/// ```no_run
53/// use deps_npm::lockfile::NpmLockParser;
54/// use deps_core::lockfile::LockFileProvider;
55/// use tower_lsp_server::ls_types::Uri;
56///
57/// # async fn example() -> deps_core::error::Result<()> {
58/// let parser = NpmLockParser;
59/// let manifest_uri = Uri::from_file_path("/path/to/package.json").unwrap();
60///
61/// if let Some(lockfile_path) = parser.locate_lockfile(&manifest_uri) {
62///     let resolved = parser.parse_lockfile(&lockfile_path).await?;
63///     println!("Found {} resolved packages", resolved.len());
64/// }
65/// # Ok(())
66/// # }
67/// ```
68pub struct NpmLockParser;
69
70impl NpmLockParser {
71    /// Lock file names for npm ecosystem.
72    const LOCKFILE_NAMES: &'static [&'static str] = &["package-lock.json"];
73}
74
75/// package-lock.json structure (partial, only fields we need).
76#[derive(Debug, Deserialize)]
77#[serde(rename_all = "camelCase")]
78struct PackageLockJson {
79    /// Packages object with resolved dependencies
80    #[serde(default)]
81    packages: HashMap<String, PackageEntry>,
82}
83
84/// Individual package entry in the "packages" object.
85#[derive(Debug, Deserialize)]
86struct PackageEntry {
87    /// Package version
88    version: Option<String>,
89
90    /// Registry URL where package was downloaded from
91    resolved: Option<String>,
92
93    /// Integrity hash (sha512-... format)
94    integrity: Option<String>,
95
96    /// True for local packages
97    link: Option<bool>,
98
99    /// Dependencies of this package (optional, for dependency tree)
100    #[serde(default)]
101    dependencies: HashMap<String, String>,
102}
103
104#[async_trait]
105impl LockFileProvider for NpmLockParser {
106    fn locate_lockfile(&self, manifest_uri: &Uri) -> Option<PathBuf> {
107        locate_lockfile_for_manifest(manifest_uri, Self::LOCKFILE_NAMES)
108    }
109
110    async fn parse_lockfile(&self, lockfile_path: &Path) -> Result<ResolvedPackages> {
111        tracing::debug!("Parsing package-lock.json: {}", lockfile_path.display());
112
113        let content = tokio::fs::read_to_string(lockfile_path)
114            .await
115            .map_err(|e| DepsError::ParseError {
116                file_type: format!("package-lock.json at {}", lockfile_path.display()),
117                source: Box::new(e),
118            })?;
119
120        let lock_data: PackageLockJson =
121            serde_json::from_str(&content).map_err(|e| DepsError::ParseError {
122                file_type: "package-lock.json".into(),
123                source: Box::new(e),
124            })?;
125
126        let mut packages = ResolvedPackages::new();
127
128        for (key, entry) in lock_data.packages {
129            // Skip root package (empty key)
130            if key.is_empty() {
131                continue;
132            }
133
134            // Extract package name from key (e.g., "node_modules/express" -> "express")
135            let name = extract_package_name(&key);
136
137            // Version is required for actual dependencies
138            let Some(ref version) = entry.version else {
139                tracing::debug!("Skipping package '{}' with no version", name);
140                continue;
141            };
142
143            // Parse source based on link, resolved, and integrity fields
144            let source = parse_npm_source(&entry);
145
146            // Extract dependency names
147            let dependencies: Vec<String> = entry.dependencies.keys().cloned().collect();
148
149            packages.insert(ResolvedPackage {
150                name: name.to_string(),
151                version: version.clone(),
152                source,
153                dependencies,
154            });
155        }
156
157        tracing::info!(
158            "Parsed package-lock.json: {} packages from {}",
159            packages.len(),
160            lockfile_path.display()
161        );
162
163        Ok(packages)
164    }
165}
166
167/// Extracts package name from lockfile key.
168///
169/// # Examples
170///
171/// - `"node_modules/express"` → `"express"`
172/// - `"node_modules/@babel/core"` → `"@babel/core"`
173/// - `"node_modules/express/node_modules/debug"` → `"debug"`
174fn extract_package_name(key: &str) -> &str {
175    // Find the last occurrence of "node_modules/"
176    key.rsplit("node_modules/").next().unwrap_or(key)
177}
178
179/// Parses npm source information into ResolvedSource.
180///
181/// # Source Detection
182///
183/// - `link: true` → Path (local package)
184/// - `resolved` URL with `integrity` → Registry
185/// - `resolved` git URL → Git
186/// - No `resolved` → Path (workspace dependency)
187fn parse_npm_source(entry: &PackageEntry) -> ResolvedSource {
188    // Local packages (link: true)
189    if entry.link == Some(true) {
190        return ResolvedSource::Path {
191            path: String::new(),
192        };
193    }
194
195    // Parse resolved URL
196    if let Some(resolved_url) = &entry.resolved {
197        // Git sources (various formats)
198        if resolved_url.starts_with("git+")
199            || resolved_url.starts_with("git://")
200            || resolved_url.contains("github.com")
201                && (resolved_url.contains(".git") || resolved_url.contains("/tarball/"))
202        {
203            return parse_git_source(resolved_url);
204        }
205
206        // Registry source with integrity
207        if let Some(integrity) = &entry.integrity {
208            return ResolvedSource::Registry {
209                url: resolved_url.clone(),
210                checksum: integrity.clone(),
211            };
212        }
213
214        // Registry without integrity (shouldn't happen in v2+, but handle it)
215        return ResolvedSource::Registry {
216            url: resolved_url.clone(),
217            checksum: String::new(),
218        };
219    }
220
221    // No resolved URL means local/workspace dependency
222    ResolvedSource::Path {
223        path: String::new(),
224    }
225}
226
227/// Parses Git source URL and extracts commit hash.
228///
229/// # Git URL Formats
230///
231/// - `git+https://github.com/user/repo.git#abc123` → rev: abc123
232/// - `https://github.com/user/repo/tarball/abc123` → rev: abc123
233/// - `git://github.com/user/repo.git#v1.0.0` → rev: v1.0.0
234fn parse_git_source(url: &str) -> ResolvedSource {
235    // Try to extract commit hash from URL
236    let (clean_url, rev) = if let Some((base, hash)) = url.split_once('#') {
237        (base.to_string(), hash.to_string())
238    } else if url.contains("/tarball/") {
239        // GitHub tarball URL: .../tarball/commitish
240        if let Some(idx) = url.rfind("/tarball/") {
241            let base = &url[..idx];
242            let hash = &url[idx + 9..]; // len("/tarball/") = 9
243            (base.to_string(), hash.to_string())
244        } else {
245            (url.to_string(), String::new())
246        }
247    } else {
248        (url.to_string(), String::new())
249    };
250
251    // Remove git+ prefix if present
252    let clean_url = clean_url
253        .strip_prefix("git+")
254        .unwrap_or(&clean_url)
255        .to_string();
256
257    ResolvedSource::Git {
258        url: clean_url,
259        rev,
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266
267    #[test]
268    fn test_extract_package_name_simple() {
269        assert_eq!(extract_package_name("node_modules/express"), "express");
270    }
271
272    #[test]
273    fn test_extract_package_name_scoped() {
274        assert_eq!(
275            extract_package_name("node_modules/@babel/core"),
276            "@babel/core"
277        );
278    }
279
280    #[test]
281    fn test_extract_package_name_nested() {
282        assert_eq!(
283            extract_package_name("node_modules/express/node_modules/debug"),
284            "debug"
285        );
286    }
287
288    #[test]
289    fn test_parse_npm_source_registry() {
290        let entry = PackageEntry {
291            version: Some("4.18.2".into()),
292            resolved: Some("https://registry.npmjs.org/express/-/express-4.18.2.tgz".into()),
293            integrity: Some("sha512-abc123".into()),
294            link: None,
295            dependencies: HashMap::new(),
296        };
297
298        let source = parse_npm_source(&entry);
299
300        match source {
301            ResolvedSource::Registry { url, checksum } => {
302                assert_eq!(
303                    url,
304                    "https://registry.npmjs.org/express/-/express-4.18.2.tgz"
305                );
306                assert_eq!(checksum, "sha512-abc123");
307            }
308            _ => panic!("Expected Registry source"),
309        }
310    }
311
312    #[test]
313    fn test_parse_npm_source_link() {
314        let entry = PackageEntry {
315            version: Some("1.0.0".into()),
316            resolved: None,
317            integrity: None,
318            link: Some(true),
319            dependencies: HashMap::new(),
320        };
321
322        let source = parse_npm_source(&entry);
323
324        match source {
325            ResolvedSource::Path { .. } => {}
326            _ => panic!("Expected Path source"),
327        }
328    }
329
330    #[test]
331    fn test_parse_git_source_with_hash() {
332        let source = parse_git_source("git+https://github.com/user/repo.git#abc123");
333
334        match source {
335            ResolvedSource::Git { url, rev } => {
336                assert_eq!(url, "https://github.com/user/repo.git");
337                assert_eq!(rev, "abc123");
338            }
339            _ => panic!("Expected Git source"),
340        }
341    }
342
343    #[test]
344    fn test_parse_git_source_tarball() {
345        let source = parse_git_source("https://github.com/user/repo/tarball/abc123");
346
347        match source {
348            ResolvedSource::Git { url, rev } => {
349                assert_eq!(url, "https://github.com/user/repo");
350                assert_eq!(rev, "abc123");
351            }
352            _ => panic!("Expected Git source"),
353        }
354    }
355
356    #[test]
357    fn test_parse_git_source_no_hash() {
358        let source = parse_git_source("git+https://github.com/user/repo.git");
359
360        match source {
361            ResolvedSource::Git { url, rev } => {
362                assert_eq!(url, "https://github.com/user/repo.git");
363                assert!(rev.is_empty());
364            }
365            _ => panic!("Expected Git source"),
366        }
367    }
368
369    #[tokio::test]
370    async fn test_parse_simple_package_lock() {
371        let lockfile_content = r#"{
372  "name": "my-project",
373  "lockfileVersion": 3,
374  "packages": {
375    "": {
376      "name": "my-project",
377      "dependencies": {
378        "express": "^4.18.0"
379      }
380    },
381    "node_modules/express": {
382      "version": "4.18.2",
383      "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
384      "integrity": "sha512-abc123",
385      "dependencies": {
386        "body-parser": "1.20.1"
387      }
388    },
389    "node_modules/body-parser": {
390      "version": "1.20.1",
391      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.1.tgz",
392      "integrity": "sha512-def456"
393    }
394  }
395}"#;
396
397        let temp_dir = tempfile::tempdir().unwrap();
398        let lockfile_path = temp_dir.path().join("package-lock.json");
399        tokio::fs::write(&lockfile_path, lockfile_content)
400            .await
401            .unwrap();
402
403        let parser = NpmLockParser;
404        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
405
406        assert_eq!(resolved.len(), 2);
407        assert_eq!(resolved.get_version("express"), Some("4.18.2"));
408        assert_eq!(resolved.get_version("body-parser"), Some("1.20.1"));
409
410        let express_pkg = resolved.get("express").unwrap();
411        assert_eq!(express_pkg.dependencies.len(), 1);
412        assert_eq!(express_pkg.dependencies[0], "body-parser");
413    }
414
415    #[tokio::test]
416    async fn test_parse_package_lock_with_git() {
417        let lockfile_content = r#"{
418  "lockfileVersion": 3,
419  "packages": {
420    "": {
421      "dependencies": {
422        "my-git-dep": "github:user/repo#abc123"
423      }
424    },
425    "node_modules/my-git-dep": {
426      "version": "0.1.0",
427      "resolved": "git+https://github.com/user/repo.git#abc123"
428    }
429  }
430}"#;
431
432        let temp_dir = tempfile::tempdir().unwrap();
433        let lockfile_path = temp_dir.path().join("package-lock.json");
434        tokio::fs::write(&lockfile_path, lockfile_content)
435            .await
436            .unwrap();
437
438        let parser = NpmLockParser;
439        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
440
441        assert_eq!(resolved.len(), 1);
442        let pkg = resolved.get("my-git-dep").unwrap();
443        assert_eq!(pkg.version, "0.1.0");
444
445        match &pkg.source {
446            ResolvedSource::Git { url, rev } => {
447                assert_eq!(url, "https://github.com/user/repo.git");
448                assert_eq!(rev, "abc123");
449            }
450            _ => panic!("Expected Git source"),
451        }
452    }
453
454    #[tokio::test]
455    async fn test_parse_package_lock_with_local() {
456        let lockfile_content = r#"{
457  "lockfileVersion": 3,
458  "packages": {
459    "": {
460      "dependencies": {
461        "my-local": "file:../my-local"
462      }
463    },
464    "node_modules/my-local": {
465      "version": "1.0.0",
466      "link": true
467    }
468  }
469}"#;
470
471        let temp_dir = tempfile::tempdir().unwrap();
472        let lockfile_path = temp_dir.path().join("package-lock.json");
473        tokio::fs::write(&lockfile_path, lockfile_content)
474            .await
475            .unwrap();
476
477        let parser = NpmLockParser;
478        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
479
480        assert_eq!(resolved.len(), 1);
481        let pkg = resolved.get("my-local").unwrap();
482
483        match &pkg.source {
484            ResolvedSource::Path { .. } => {}
485            _ => panic!("Expected Path source for local package"),
486        }
487    }
488
489    #[tokio::test]
490    async fn test_parse_empty_package_lock() {
491        let lockfile_content = r#"{
492  "lockfileVersion": 3,
493  "packages": {
494    "": {
495      "name": "empty-project"
496    }
497  }
498}"#;
499
500        let temp_dir = tempfile::tempdir().unwrap();
501        let lockfile_path = temp_dir.path().join("package-lock.json");
502        tokio::fs::write(&lockfile_path, lockfile_content)
503            .await
504            .unwrap();
505
506        let parser = NpmLockParser;
507        let resolved = parser.parse_lockfile(&lockfile_path).await.unwrap();
508
509        assert_eq!(resolved.len(), 0);
510        assert!(resolved.is_empty());
511    }
512
513    #[tokio::test]
514    async fn test_parse_malformed_package_lock() {
515        let lockfile_content = "not valid json {{{";
516
517        let temp_dir = tempfile::tempdir().unwrap();
518        let lockfile_path = temp_dir.path().join("package-lock.json");
519        tokio::fs::write(&lockfile_path, lockfile_content)
520            .await
521            .unwrap();
522
523        let parser = NpmLockParser;
524        let result = parser.parse_lockfile(&lockfile_path).await;
525
526        assert!(result.is_err());
527    }
528
529    #[test]
530    fn test_locate_lockfile_same_directory() {
531        let temp_dir = tempfile::tempdir().unwrap();
532        let manifest_path = temp_dir.path().join("package.json");
533        let lock_path = temp_dir.path().join("package-lock.json");
534
535        std::fs::write(&manifest_path, r#"{"name": "test"}"#).unwrap();
536        std::fs::write(&lock_path, r#"{"lockfileVersion": 3}"#).unwrap();
537
538        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
539        let parser = NpmLockParser;
540
541        let located = parser.locate_lockfile(&manifest_uri);
542        assert!(located.is_some());
543        assert_eq!(located.unwrap(), lock_path);
544    }
545
546    #[test]
547    fn test_locate_lockfile_workspace_root() {
548        let temp_dir = tempfile::tempdir().unwrap();
549        let workspace_lock = temp_dir.path().join("package-lock.json");
550        let member_dir = temp_dir.path().join("packages").join("member");
551        std::fs::create_dir_all(&member_dir).unwrap();
552        let member_manifest = member_dir.join("package.json");
553
554        std::fs::write(&workspace_lock, r#"{"lockfileVersion": 3}"#).unwrap();
555        std::fs::write(&member_manifest, r#"{"name": "member"}"#).unwrap();
556
557        let manifest_uri = Uri::from_file_path(&member_manifest).unwrap();
558        let parser = NpmLockParser;
559
560        let located = parser.locate_lockfile(&manifest_uri);
561        assert!(located.is_some());
562        assert_eq!(located.unwrap(), workspace_lock);
563    }
564
565    #[test]
566    fn test_locate_lockfile_not_found() {
567        let temp_dir = tempfile::tempdir().unwrap();
568        let manifest_path = temp_dir.path().join("package.json");
569        std::fs::write(&manifest_path, r#"{"name": "test"}"#).unwrap();
570
571        let manifest_uri = Uri::from_file_path(&manifest_path).unwrap();
572        let parser = NpmLockParser;
573
574        let located = parser.locate_lockfile(&manifest_uri);
575        assert!(located.is_none());
576    }
577
578    #[test]
579    fn test_is_lockfile_stale_not_modified() {
580        let temp_dir = tempfile::tempdir().unwrap();
581        let lockfile_path = temp_dir.path().join("package-lock.json");
582        std::fs::write(&lockfile_path, r#"{"lockfileVersion": 3}"#).unwrap();
583
584        let mtime = std::fs::metadata(&lockfile_path)
585            .unwrap()
586            .modified()
587            .unwrap();
588        let parser = NpmLockParser;
589
590        assert!(
591            !parser.is_lockfile_stale(&lockfile_path, mtime),
592            "Lock file should not be stale when mtime matches"
593        );
594    }
595
596    #[test]
597    fn test_is_lockfile_stale_modified() {
598        let temp_dir = tempfile::tempdir().unwrap();
599        let lockfile_path = temp_dir.path().join("package-lock.json");
600        std::fs::write(&lockfile_path, r#"{"lockfileVersion": 3}"#).unwrap();
601
602        let old_time = std::time::UNIX_EPOCH;
603        let parser = NpmLockParser;
604
605        assert!(
606            parser.is_lockfile_stale(&lockfile_path, old_time),
607            "Lock file should be stale when last_modified is old"
608        );
609    }
610
611    #[test]
612    fn test_is_lockfile_stale_deleted() {
613        let parser = NpmLockParser;
614        let non_existent = std::path::Path::new("/nonexistent/package-lock.json");
615
616        assert!(
617            parser.is_lockfile_stale(non_existent, std::time::SystemTime::now()),
618            "Non-existent lock file should be considered stale"
619        );
620    }
621
622    #[test]
623    fn test_is_lockfile_stale_future_time() {
624        let temp_dir = tempfile::tempdir().unwrap();
625        let lockfile_path = temp_dir.path().join("package-lock.json");
626        std::fs::write(&lockfile_path, r#"{"lockfileVersion": 3}"#).unwrap();
627
628        // Use a time far in the future
629        let future_time = std::time::SystemTime::now() + std::time::Duration::from_secs(86400); // +1 day
630        let parser = NpmLockParser;
631
632        assert!(
633            !parser.is_lockfile_stale(&lockfile_path, future_time),
634            "Lock file should not be stale when last_modified is in the future"
635        );
636    }
637}