1use 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
39pub struct NpmLockParser;
69
70impl NpmLockParser {
71 const LOCKFILE_NAMES: &'static [&'static str] = &["package-lock.json"];
73}
74
75#[derive(Debug, Deserialize)]
77#[serde(rename_all = "camelCase")]
78struct PackageLockJson {
79 #[serde(default)]
81 packages: HashMap<String, PackageEntry>,
82}
83
84#[derive(Debug, Deserialize)]
86struct PackageEntry {
87 version: Option<String>,
89
90 resolved: Option<String>,
92
93 integrity: Option<String>,
95
96 link: Option<bool>,
98
99 #[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 if key.is_empty() {
131 continue;
132 }
133
134 let name = extract_package_name(&key);
136
137 let Some(ref version) = entry.version else {
139 tracing::debug!("Skipping package '{}' with no version", name);
140 continue;
141 };
142
143 let source = parse_npm_source(&entry);
145
146 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
167fn extract_package_name(key: &str) -> &str {
175 key.rsplit("node_modules/").next().unwrap_or(key)
177}
178
179fn parse_npm_source(entry: &PackageEntry) -> ResolvedSource {
188 if entry.link == Some(true) {
190 return ResolvedSource::Path {
191 path: String::new(),
192 };
193 }
194
195 if let Some(resolved_url) = &entry.resolved {
197 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 if let Some(integrity) = &entry.integrity {
208 return ResolvedSource::Registry {
209 url: resolved_url.clone(),
210 checksum: integrity.clone(),
211 };
212 }
213
214 return ResolvedSource::Registry {
216 url: resolved_url.clone(),
217 checksum: String::new(),
218 };
219 }
220
221 ResolvedSource::Path {
223 path: String::new(),
224 }
225}
226
227fn parse_git_source(url: &str) -> ResolvedSource {
235 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 if let Some(idx) = url.rfind("/tarball/") {
241 let base = &url[..idx];
242 let hash = &url[idx + 9..]; (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 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 let future_time = std::time::SystemTime::now() + std::time::Duration::from_secs(86400); 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}