1use crate::models::{DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage};
24use crate::parsers::utils::{npm_purl, parse_sri};
25use log::warn;
26use serde_json::Value as JsonValue;
27use serde_yaml::Value;
28use std::collections::{HashMap, HashSet};
29use std::fs;
30use std::path::Path;
31
32use super::PackageParser;
33
34pub struct YarnLockParser;
38
39#[derive(Clone, Debug, PartialEq, Eq)]
40struct ManifestDependencyInfo {
41 scope: &'static str,
42 is_runtime: bool,
43 is_optional: bool,
44}
45
46impl PackageParser for YarnLockParser {
47 const PACKAGE_TYPE: PackageType = PackageType::Npm;
48
49 fn is_match(path: &Path) -> bool {
50 path.file_name()
51 .and_then(|name| name.to_str())
52 .map(|name| name == "yarn.lock")
53 .unwrap_or(false)
54 }
55
56 fn extract_packages(path: &Path) -> Vec<PackageData> {
57 let content = match fs::read_to_string(path) {
58 Ok(content) => content,
59 Err(e) => {
60 warn!("Failed to read yarn.lock at {:?}: {}", path, e);
61 return vec![default_package_data()];
62 }
63 };
64
65 let is_v2 = detect_yarn_version(&content);
66 let manifest_dependencies = load_manifest_dependency_info(path);
67
68 vec![if is_v2 {
69 parse_yarn_v2(&content, &manifest_dependencies)
70 } else {
71 parse_yarn_v1(&content, &manifest_dependencies)
72 }]
73 }
74}
75
76pub fn detect_yarn_version(content: &str) -> bool {
78 content
79 .lines()
80 .take(10)
81 .any(|line| line.contains("__metadata:"))
82}
83
84fn parse_yarn_v2(
86 content: &str,
87 manifest_dependencies: &HashMap<String, ManifestDependencyInfo>,
88) -> PackageData {
89 let yaml_value: Value = match serde_yaml::from_str(content) {
90 Ok(val) => val,
91 Err(e) => {
92 warn!("Failed to parse yarn.lock v2 YAML: {}", e);
93 return default_package_data();
94 }
95 };
96
97 let yaml_map = match yaml_value.as_mapping() {
98 Some(map) => map,
99 None => return default_package_data(),
100 };
101
102 let mut dependencies = Vec::new();
103 let package_extra_data = extract_yarn_v2_package_extra_data(yaml_map);
104
105 for (spec, details) in yaml_map {
106 if spec.as_str().map(|s| s == "__metadata").unwrap_or(false) {
107 continue;
108 }
109
110 let _spec_str = match spec.as_str() {
111 Some(s) => s,
112 None => continue,
113 };
114
115 let details_map = match details.as_mapping() {
116 Some(map) => map,
117 None => continue,
118 };
119
120 let _version = extract_yaml_string(details_map, "version").unwrap_or_default();
121 let resolution = extract_yaml_string(details_map, "resolution").unwrap_or_default();
122
123 let (namespace_opt, name, resolved_version) = parse_yarn_v2_resolution(&resolution);
124 let namespace = namespace_opt.unwrap_or_default();
125 let full_name = full_package_name(&namespace, &name);
126 let manifest_info = manifest_dependencies.get(&full_name);
127 let purl = create_purl(&namespace, &name, &resolved_version);
128 let checksum = extract_yaml_string(details_map, "checksum");
129
130 let deps_yaml = details_map.get("dependencies");
131 let peer_deps_yaml = details_map.get("peerDependencies");
132 let resolved_extra_data = extract_yarn_v2_resolved_extra_data(details_map, &resolution);
133
134 let nested_deps = parse_yaml_dependencies(deps_yaml);
135 let peer_deps = parse_yaml_dependencies(peer_deps_yaml);
136
137 let all_deps = if peer_deps.is_empty() {
138 nested_deps
139 } else {
140 let mut combined = nested_deps;
141 for mut dep in peer_deps {
142 dep.scope = Some("peerDependencies".to_string());
143 dep.is_optional = Some(true);
144 dep.is_runtime = Some(false);
145 combined.push(dep);
146 }
147 combined
148 };
149
150 let resolved_package = ResolvedPackage {
151 package_type: YarnLockParser::PACKAGE_TYPE,
152 namespace: namespace.clone(),
153 name: name.clone(),
154 version: resolved_version.clone(),
155 primary_language: Some("JavaScript".to_string()),
156 download_url: None,
157 sha1: None,
158 sha256: None,
159 sha512: checksum,
160 md5: None,
161 is_virtual: true,
162 extra_data: resolved_extra_data,
163 dependencies: all_deps,
164 repository_homepage_url: None,
165 repository_download_url: None,
166 api_data_url: None,
167 datasource_id: Some(DatasourceId::YarnLock),
168 purl: None,
169 };
170
171 let (scope, is_runtime, is_optional, is_direct) = manifest_info
172 .map(|info| {
173 (
174 info.scope.to_string(),
175 info.is_runtime,
176 info.is_optional,
177 true,
178 )
179 })
180 .unwrap_or_else(|| {
181 (
182 "dependencies".to_string(),
183 true,
184 false,
185 resolution.contains("workspace:"),
186 )
187 });
188
189 let dependency = Dependency {
190 purl,
191 extracted_requirement: Some(resolved_version.clone()),
192 scope: Some(scope),
193 is_runtime: Some(is_runtime),
194 is_optional: Some(is_optional),
195 is_pinned: Some(true),
196 is_direct: Some(is_direct),
197 resolved_package: Some(Box::new(resolved_package)),
198 extra_data: Some(HashMap::from([(
199 "resolution".to_string(),
200 JsonValue::String(resolution),
201 )])),
202 };
203
204 dependencies.push(dependency);
205 }
206
207 PackageData {
208 package_type: Some(YarnLockParser::PACKAGE_TYPE),
209 namespace: None,
210 name: None,
211 version: None,
212 qualifiers: None,
213 subpath: None,
214 primary_language: None,
215 description: None,
216 release_date: None,
217 parties: Vec::new(),
218 keywords: Vec::new(),
219 homepage_url: None,
220 download_url: None,
221 size: None,
222 sha1: None,
223 md5: None,
224 sha256: None,
225 sha512: None,
226 bug_tracking_url: None,
227 code_view_url: None,
228 vcs_url: None,
229 copyright: None,
230 holder: None,
231 declared_license_expression: None,
232 declared_license_expression_spdx: None,
233 license_detections: Vec::new(),
234 other_license_expression: None,
235 other_license_expression_spdx: None,
236 other_license_detections: Vec::new(),
237 extracted_license_statement: None,
238 notice_text: None,
239 source_packages: Vec::new(),
240 file_references: Vec::new(),
241 is_private: false,
242 is_virtual: false,
243 extra_data: package_extra_data,
244 dependencies,
245 repository_homepage_url: None,
246 repository_download_url: None,
247 api_data_url: None,
248 datasource_id: Some(DatasourceId::YarnLock),
249 purl: None,
250 }
251}
252
253fn parse_yarn_v1(
255 content: &str,
256 manifest_dependencies: &HashMap<String, ManifestDependencyInfo>,
257) -> PackageData {
258 let mut dependencies = Vec::new();
259 let mut seen_purls = HashSet::new();
260
261 for block in content.split("\n\n") {
262 if is_empty_or_comment_block(block) {
263 continue;
264 }
265
266 if let Some(dep) = parse_yarn_v1_block(block, manifest_dependencies) {
267 if let Some(ref purl) = dep.purl {
268 if seen_purls.insert(purl.clone()) {
269 dependencies.push(dep);
270 }
271 } else {
272 dependencies.push(dep);
273 }
274 }
275 }
276
277 PackageData {
278 package_type: Some(YarnLockParser::PACKAGE_TYPE),
279 namespace: None,
280 name: None,
281 version: None,
282 qualifiers: None,
283 subpath: None,
284 primary_language: None,
285 description: None,
286 release_date: None,
287 parties: Vec::new(),
288 keywords: Vec::new(),
289 homepage_url: None,
290 download_url: None,
291 size: None,
292 sha1: None,
293 md5: None,
294 sha256: None,
295 sha512: None,
296 bug_tracking_url: None,
297 code_view_url: None,
298 vcs_url: None,
299 copyright: None,
300 holder: None,
301 declared_license_expression: None,
302 declared_license_expression_spdx: None,
303 license_detections: Vec::new(),
304 other_license_expression: None,
305 other_license_expression_spdx: None,
306 other_license_detections: Vec::new(),
307 extracted_license_statement: None,
308 notice_text: None,
309 source_packages: Vec::new(),
310 file_references: Vec::new(),
311 is_private: false,
312 is_virtual: false,
313 extra_data: None,
314 dependencies,
315 repository_homepage_url: None,
316 repository_download_url: None,
317 api_data_url: None,
318 datasource_id: Some(DatasourceId::YarnLock),
319 purl: None,
320 }
321}
322
323fn is_empty_or_comment_block(block: &str) -> bool {
324 block
325 .lines()
326 .all(|line| line.trim().is_empty() || line.trim().starts_with('#'))
327}
328
329fn parse_integrity_field(integrity: &str) -> Option<String> {
331 parse_sri(integrity).and_then(|(algo, hex_digest)| {
332 if algo == "sha512" {
333 Some(hex_digest)
334 } else {
335 None
336 }
337 })
338}
339
340pub fn extract_namespace_and_name(package_name: &str) -> (String, String) {
342 if package_name.starts_with('@') {
343 let parts: Vec<&str> = package_name.splitn(2, '/').collect();
344 if parts.len() == 2 {
345 (parts[0].to_string(), parts[1].to_string())
346 } else {
347 (String::new(), package_name.to_string())
348 }
349 } else {
350 (String::new(), package_name.to_string())
351 }
352}
353
354fn create_purl(namespace: &str, name: &str, version: &str) -> Option<String> {
355 let full_name = if namespace.is_empty() {
356 name.to_string()
357 } else {
358 format!("{}/{}", namespace, name)
359 };
360 let version_opt = if version.is_empty() {
361 None
362 } else {
363 Some(version)
364 };
365 npm_purl(&full_name, version_opt)
366}
367
368fn default_package_data() -> PackageData {
369 PackageData {
370 package_type: Some(YarnLockParser::PACKAGE_TYPE),
371 datasource_id: Some(DatasourceId::YarnLock),
372 ..Default::default()
373 }
374}
375
376fn parse_yarn_v1_block(
378 block: &str,
379 manifest_dependencies: &HashMap<String, ManifestDependencyInfo>,
380) -> Option<Dependency> {
381 let lines: Vec<&str> = block.lines().collect();
382 if lines.is_empty() {
383 return None;
384 }
385
386 let requirement_line = lines[0]
387 .trim()
388 .strip_suffix(':')
389 .unwrap_or_else(|| lines[0].trim())
390 .trim_matches('"');
391 if requirement_line.is_empty() || requirement_line.starts_with('#') {
392 return None;
393 }
394
395 let (namespace, name, constraint) = parse_yarn_v1_requirement(requirement_line);
396
397 if name.is_empty() {
398 return None;
399 }
400
401 let mut version = String::new();
402 let mut resolved_url = String::new();
403 let mut integrity = String::new();
404 let mut nested_deps = Vec::new();
405
406 for line in &lines[1..] {
407 let trimmed = line.trim();
408 if trimmed.is_empty() {
409 continue;
410 }
411
412 if trimmed.starts_with("version") {
413 version = extract_quoted_value(trimmed).unwrap_or_default();
414 } else if trimmed.starts_with("resolved") {
415 resolved_url = extract_quoted_value(trimmed).unwrap_or_default();
416 } else if trimmed.starts_with("integrity") {
417 integrity = trimmed
418 .strip_prefix("integrity")
419 .map(|s| s.trim().to_string())
420 .unwrap_or_default();
421 } else if trimmed.starts_with("dependencies") {
422 continue;
424 } else if trimmed.starts_with(" ") && !trimmed.starts_with(" ") {
425 let dep_line = trimmed.trim();
427 if let Some(dep) = parse_yarn_v1_dependency_line(dep_line, &namespace, &name, &version)
428 {
429 nested_deps.push(dep);
430 }
431 }
432 }
433
434 let sha512 = if integrity.is_empty() {
435 None
436 } else {
437 parse_integrity_field(&integrity)
438 };
439
440 let full_name = full_package_name(&namespace, &name);
441 let manifest_info = manifest_dependencies.get(&full_name);
442 let purl = create_purl(&namespace, &name, &version);
443 let (scope, is_runtime, is_optional, is_direct) = manifest_info
444 .map(|info| {
445 (
446 info.scope.to_string(),
447 info.is_runtime,
448 info.is_optional,
449 true,
450 )
451 })
452 .unwrap_or_else(|| ("dependencies".to_string(), true, false, false));
453
454 let resolved_package = ResolvedPackage {
455 package_type: YarnLockParser::PACKAGE_TYPE,
456 namespace: namespace.clone(),
457 name: name.clone(),
458 version: version.clone(),
459 primary_language: Some("JavaScript".to_string()),
460 download_url: if resolved_url.is_empty() {
461 None
462 } else {
463 Some(resolved_url)
464 },
465 sha1: None,
466 sha256: None,
467 sha512,
468 md5: None,
469 is_virtual: true,
470 extra_data: None,
471 dependencies: nested_deps,
472 repository_homepage_url: None,
473 repository_download_url: None,
474 api_data_url: None,
475 datasource_id: Some(DatasourceId::YarnLock),
476 purl: None,
477 };
478
479 Some(Dependency {
480 purl,
481 extracted_requirement: Some(constraint),
482 scope: Some(scope),
483 is_runtime: Some(is_runtime),
484 is_optional: Some(is_optional),
485 is_pinned: Some(true),
486 is_direct: Some(is_direct),
487 resolved_package: Some(Box::new(resolved_package)),
488 extra_data: None,
489 })
490}
491
492fn full_package_name(namespace: &str, name: &str) -> String {
493 if namespace.is_empty() {
494 name.to_string()
495 } else {
496 format!("{namespace}/{name}")
497 }
498}
499
500fn load_manifest_dependency_info(path: &Path) -> HashMap<String, ManifestDependencyInfo> {
501 let Some(parent) = path.parent() else {
502 return HashMap::new();
503 };
504
505 let manifest_path = parent.join("package.json");
506 let Ok(content) = fs::read_to_string(manifest_path) else {
507 return HashMap::new();
508 };
509
510 let Ok(json) = serde_json::from_str::<JsonValue>(&content) else {
511 return HashMap::new();
512 };
513
514 let peer_optional = json
515 .get("peerDependenciesMeta")
516 .and_then(|value| value.as_object())
517 .map(|meta| {
518 meta.iter()
519 .filter_map(|(name, value)| {
520 value
521 .as_object()
522 .and_then(|entry| entry.get("optional"))
523 .and_then(|value| value.as_bool())
524 .map(|optional| (name.clone(), optional))
525 })
526 .collect::<HashMap<_, _>>()
527 })
528 .unwrap_or_default();
529
530 let mut dependencies = HashMap::new();
531 insert_manifest_dependency_info(
532 &mut dependencies,
533 &json,
534 "dependencies",
535 ManifestDependencyInfo {
536 scope: "dependencies",
537 is_runtime: true,
538 is_optional: false,
539 },
540 );
541 insert_manifest_dependency_info(
542 &mut dependencies,
543 &json,
544 "devDependencies",
545 ManifestDependencyInfo {
546 scope: "devDependencies",
547 is_runtime: false,
548 is_optional: true,
549 },
550 );
551 insert_manifest_dependency_info(
552 &mut dependencies,
553 &json,
554 "optionalDependencies",
555 ManifestDependencyInfo {
556 scope: "optionalDependencies",
557 is_runtime: true,
558 is_optional: true,
559 },
560 );
561
562 if let Some(peer_dependencies) = json
563 .get("peerDependencies")
564 .and_then(|value| value.as_object())
565 {
566 for name in peer_dependencies.keys() {
567 dependencies.insert(
568 name.clone(),
569 ManifestDependencyInfo {
570 scope: "peerDependencies",
571 is_runtime: false,
572 is_optional: peer_optional.get(name).copied().unwrap_or(false),
573 },
574 );
575 }
576 }
577
578 dependencies
579}
580
581fn insert_manifest_dependency_info(
582 dependencies: &mut HashMap<String, ManifestDependencyInfo>,
583 json: &JsonValue,
584 field: &str,
585 info: ManifestDependencyInfo,
586) {
587 if let Some(entries) = json.get(field).and_then(|value| value.as_object()) {
588 for name in entries.keys() {
589 dependencies.insert(name.clone(), info.clone());
590 }
591 }
592}
593
594pub fn parse_yarn_v1_requirement(line: &str) -> (String, String, String) {
596 if line.contains(", ") {
598 let first_part = line.split(", ").next().unwrap_or(line);
600 return parse_single_yarn_v1_requirement(first_part);
601 }
602 parse_single_yarn_v1_requirement(line)
603}
604
605fn parse_single_yarn_v1_requirement(line: &str) -> (String, String, String) {
607 if let Some(at_pos) = line.rfind('@') {
608 let name_part = &line[..at_pos];
609 let constraint = &line[at_pos + 1..];
610 let (namespace, name) = extract_namespace_and_name(name_part);
611
612 if !name.is_empty() {
613 return (namespace, name, constraint.to_string());
614 }
615 }
616
617 (String::new(), String::new(), String::new())
618}
619
620fn parse_yarn_v1_dependency_line(
622 line: &str,
623 _parent_namespace: &str,
624 _parent_name: &str,
625 parent_version: &str,
626) -> Option<Dependency> {
627 let trimmed = line.trim_matches('"');
628 if !trimmed.contains('@') {
629 return None;
630 }
631
632 let (namespace, name, constraint) = parse_yarn_v1_requirement(trimmed);
633
634 let purl = create_purl(&namespace, &name, parent_version);
635
636 Some(Dependency {
637 purl,
638 extracted_requirement: Some(constraint),
639 scope: Some("dependencies".to_string()),
640 is_runtime: Some(true),
641 is_optional: Some(false),
642 is_pinned: Some(false),
643 is_direct: Some(false),
644 resolved_package: None,
645 extra_data: None,
646 })
647}
648
649fn extract_quoted_value(line: &str) -> Option<String> {
651 line.find('"').and_then(|start| {
652 let rest = &line[start + 1..];
653 rest.find('"').map(|end| rest[..end].to_string())
654 })
655}
656
657pub fn parse_yarn_v2_resolution(resolution: &str) -> (Option<String>, String, String) {
659 if resolution.contains("@npm:") {
660 let parts: Vec<&str> = resolution.split("@npm:").collect();
661 if parts.len() == 2 {
662 let package_name = parts[0];
663 let version = parts[1];
664 let (namespace, name) = extract_namespace_and_name(package_name);
665 let namespace_opt = if namespace.is_empty() {
666 None
667 } else {
668 Some(namespace)
669 };
670 return (namespace_opt, name, version.to_string());
671 }
672 }
673
674 if let Some((ident, reference)) = split_yarn_locator(resolution) {
675 let (namespace, name) = extract_namespace_and_name(ident);
676 let namespace_opt = if namespace.is_empty() {
677 None
678 } else {
679 Some(namespace)
680 };
681 return (namespace_opt, name, reference.to_string());
682 }
683
684 let (namespace, name) = extract_namespace_and_name(resolution);
685 let namespace_opt = if namespace.is_empty() {
686 None
687 } else {
688 Some(namespace)
689 };
690 (namespace_opt, name, "*".to_string())
691}
692
693fn split_yarn_locator(resolution: &str) -> Option<(&str, &str)> {
694 if resolution.is_empty() {
695 return None;
696 }
697
698 let separator_index = if resolution.starts_with('@') {
699 let slash_index = resolution.find('/')?;
700 let rest = &resolution[slash_index + 1..];
701 let at_index = rest.find('@')?;
702 slash_index + 1 + at_index
703 } else {
704 resolution.find('@')?
705 };
706
707 let ident = &resolution[..separator_index];
708 let reference = &resolution[separator_index + 1..];
709
710 if ident.is_empty() || reference.is_empty() {
711 None
712 } else {
713 Some((ident, reference))
714 }
715}
716
717fn extract_yarn_v2_package_extra_data(
718 yaml_map: &serde_yaml::Mapping,
719) -> Option<HashMap<String, JsonValue>> {
720 let metadata = yaml_map.get("__metadata")?.as_mapping()?;
721 let mut extra_data = HashMap::new();
722
723 for field in ["version", "cacheKey"] {
724 if let Some(value) = metadata.get(field).and_then(yaml_value_to_json) {
725 extra_data.insert(field.to_string(), value);
726 }
727 }
728
729 (!extra_data.is_empty()).then_some(extra_data)
730}
731
732fn extract_yarn_v2_resolved_extra_data(
733 details_map: &serde_yaml::Mapping,
734 resolution: &str,
735) -> Option<HashMap<String, JsonValue>> {
736 let mut extra_data = HashMap::new();
737 extra_data.insert(
738 "resolution".to_string(),
739 JsonValue::String(resolution.to_string()),
740 );
741
742 for field in ["languageName", "linkType", "bin", "dependenciesMeta"] {
743 if let Some(value) = details_map.get(field).and_then(yaml_value_to_json) {
744 extra_data.insert(field.to_string(), value);
745 }
746 }
747
748 Some(extra_data)
749}
750
751fn yaml_value_to_json(value: &Value) -> Option<JsonValue> {
752 serde_json::to_value(value).ok()
753}
754
755fn extract_yaml_string(map: &serde_yaml::Mapping, key: &str) -> Option<String> {
757 map.get(key).and_then(|v| v.as_str()).map(|s| s.to_string())
758}
759
760fn parse_yaml_dependencies(yaml_value: Option<&Value>) -> Vec<Dependency> {
762 let mut dependencies = Vec::new();
763
764 if let Some(deps_value) = yaml_value
765 && let Some(mapping) = deps_value.as_mapping()
766 {
767 for (key, value) in mapping {
768 let name = match key.as_str() {
769 Some(s) => s.to_string(),
770 None => continue,
771 };
772
773 let constraint = match value.as_str() {
774 Some(s) => s.to_string(),
775 None => "*".to_string(),
776 };
777
778 let (namespace, dep_name) = extract_namespace_and_name(&name);
779 let purl = create_purl(&namespace, &dep_name, &constraint);
780
781 dependencies.push(Dependency {
782 purl,
783 extracted_requirement: Some(constraint),
784 scope: Some("dependencies".to_string()),
785 is_runtime: Some(true),
786 is_optional: Some(false),
787 is_pinned: Some(false),
788 is_direct: Some(false),
789 resolved_package: None,
790 extra_data: None,
791 });
792 }
793 }
794 dependencies
795}
796
797#[cfg(test)]
798mod tests {
799 use super::*;
800 use std::path::PathBuf;
801
802 #[test]
803 fn test_is_match_yarn_lock() {
804 let valid_path = PathBuf::from("/some/path/yarn.lock");
805 assert!(YarnLockParser::is_match(&valid_path));
806 }
807
808 #[test]
809 fn test_is_not_match_package_json() {
810 let invalid_path = PathBuf::from("/some/path/package.json");
811 assert!(!YarnLockParser::is_match(&invalid_path));
812 }
813
814 #[test]
815 fn test_detect_yarn_v2() {
816 let content = r#"# This file is generated by running "yarn install"
817__metadata:
818 version: 6
819"#;
820 assert!(detect_yarn_version(content));
821 }
822
823 #[test]
824 fn test_detect_yarn_v1() {
825 let content = r#"# THIS IS AN AUTOGENERATED FILE
826# yarn lockfile v1
827
828abbrev@1:
829 version "1.0.9"
830"#;
831 assert!(!detect_yarn_version(content));
832 }
833
834 #[test]
835 fn test_extract_namespace_and_name_scoped() {
836 let (namespace, name) = extract_namespace_and_name("@types/node");
837 assert_eq!(namespace, "@types");
838 assert_eq!(name, "node");
839 }
840
841 #[test]
842 fn test_extract_namespace_and_name_regular() {
843 let (namespace, name) = extract_namespace_and_name("express");
844 assert_eq!(namespace, "");
845 assert_eq!(name, "express");
846 }
847
848 #[test]
849 fn test_parse_yarn_v1_requirement() {
850 let (namespace, name, constraint) = parse_yarn_v1_requirement("express@^4.0.0");
851 assert_eq!(namespace, "");
852 assert_eq!(name, "express");
853 assert_eq!(constraint, "^4.0.0");
854 }
855
856 #[test]
857 fn test_parse_yarn_v1_requirement_scoped() {
858 let (namespace, name, constraint) = parse_yarn_v1_requirement("@types/node@^18.0.0");
859 assert_eq!(namespace, "@types");
860 assert_eq!(name, "node");
861 assert_eq!(constraint, "^18.0.0");
862 }
863
864 #[test]
865 fn test_parse_yarn_v2_resolution() {
866 let (namespace, name, version) = parse_yarn_v2_resolution("@actions/core@npm:1.2.6");
867 assert_eq!(namespace, Some("@actions".to_string()));
868 assert_eq!(name, "core");
869 assert_eq!(version, "1.2.6");
870 }
871}
872
873crate::register_parser!(
874 "yarn.lock lockfile (v1 and v2+)",
875 &["**/yarn.lock"],
876 "npm",
877 "JavaScript",
878 Some("https://classic.yarnpkg.com/lang/en/docs/yarn-lock/"),
879);