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