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