Skip to main content

provenant/parsers/
yarn_lock.rs

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