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