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