Skip to main content

provenant/parsers/
dart.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7//! Parser for Dart/Flutter pubspec.yaml and pubspec.lock files.
8//!
9//! Extracts package metadata and dependencies from Dart/Flutter project manifest
10//! and lockfiles using YAML format.
11//!
12//! # Supported Formats
13//! - pubspec.yaml (Dart package manifest)
14//! - pubspec.lock (Dart package lockfile with pinned versions)
15//!
16//! # Key Features
17//! - Dependency extraction from dependencies and dev_dependencies sections
18//! - Direct vs transitive dependency tracking (lockfile)
19//! - Version constraint parsing for Dart's SemVer and range specifiers
20//! - Package URL (purl) generation for Pub packages
21//! - Author/maintainer and homepage extraction
22//!
23//! # Implementation Notes
24//! - Uses YAML parsing via `yaml_serde`
25//! - Lockfile versions are pinned (`is_pinned: Some(true)`)
26//! - Graceful error handling with `warn!()` logs
27//! - Supports both pub.dev and Git-hosted packages
28
29use std::collections::{HashMap, HashSet, VecDeque};
30use std::path::Path;
31
32use crate::parser_warn as warn;
33use crate::parsers::utils::{
34    MAX_ITERATION_COUNT, RecursionGuard, capped_iteration_limit, read_file_to_string,
35    truncate_field,
36};
37use packageurl::PackageUrl;
38use yaml_serde::{Mapping, Value};
39
40use crate::models::{
41    DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage, Sha256Digest,
42};
43
44use super::PackageParser;
45use super::metadata::ParserMetadata;
46
47const FIELD_NAME: &str = "name";
48const FIELD_VERSION: &str = "version";
49const FIELD_DESCRIPTION: &str = "description";
50const FIELD_HOMEPAGE: &str = "homepage";
51const FIELD_LICENSE: &str = "license";
52const FIELD_REPOSITORY: &str = "repository";
53const FIELD_AUTHOR: &str = "author";
54const FIELD_AUTHORS: &str = "authors";
55const FIELD_DEPENDENCIES: &str = "dependencies";
56const FIELD_DEV_DEPENDENCIES: &str = "dev_dependencies";
57const FIELD_DEPENDENCY_OVERRIDES: &str = "dependency_overrides";
58const FIELD_ENVIRONMENT: &str = "environment";
59const FIELD_ISSUE_TRACKER: &str = "issue_tracker";
60const FIELD_DOCUMENTATION: &str = "documentation";
61const FIELD_EXECUTABLES: &str = "executables";
62const FIELD_PUBLISH_TO: &str = "publish_to";
63const FIELD_ARCHIVE_URL: &str = "archive_url";
64const FIELD_PLATFORMS: &str = "platforms";
65const FIELD_FUNDING: &str = "funding";
66const FIELD_FALSE_SECRETS: &str = "false_secrets";
67const FIELD_SCREENSHOTS: &str = "screenshots";
68const FIELD_TOPICS: &str = "topics";
69const FIELD_IGNORED_ADVISORIES: &str = "ignored_advisories";
70const FIELD_PACKAGES: &str = "packages";
71const FIELD_SDKS: &str = "sdks";
72const FIELD_SDK: &str = "sdk";
73const FIELD_DEPENDENCY: &str = "dependency";
74const FIELD_SHA256: &str = "sha256";
75
76/// Dart pubspec.yaml manifest parser.
77pub struct PubspecYamlParser;
78
79impl PackageParser for PubspecYamlParser {
80    // `pkg:dart`/`pkg:pubspec` are not registered purl-spec types; the
81    // registered type for Dart/Flutter packages is `pub`, used for both the
82    // manifest and the lockfile. The file format is captured by `DatasourceId`.
83    const PACKAGE_TYPE: PackageType = PackageType::Pub;
84
85    fn metadata() -> Vec<ParserMetadata> {
86        vec![ParserMetadata {
87            description: "Dart pubspec.yaml manifest",
88            file_patterns: &["**/pubspec.yaml", "**/pubspec.lock"],
89            package_type: "pub",
90            primary_language: "Dart",
91            documentation_url: Some("https://dart.dev/tools/pub/pubspec"),
92        }]
93    }
94
95    fn extract_packages(path: &Path) -> Vec<PackageData> {
96        let yaml_content = match read_yaml_file(path) {
97            Ok(content) => content,
98            Err(e) => {
99                warn!("Failed to read pubspec.yaml at {:?}: {}", path, e);
100                let mut package_data = default_package_data();
101                package_data.datasource_id = Some(DatasourceId::PubspecYaml);
102                return vec![package_data];
103            }
104        };
105
106        vec![parse_pubspec_yaml(&yaml_content)]
107    }
108
109    fn is_match(path: &Path) -> bool {
110        path.file_name()
111            .and_then(|name| name.to_str())
112            .is_some_and(|name| {
113                name == "pubspec.yaml"
114                    || name.ends_with("-pubspec.yaml")
115                    || name.ends_with(".pubspec.yaml")
116            })
117    }
118}
119
120/// Dart pubspec.lock lockfile parser.
121pub struct PubspecLockParser;
122
123impl PackageParser for PubspecLockParser {
124    const PACKAGE_TYPE: PackageType = PackageType::Pub;
125
126    fn extract_packages(path: &Path) -> Vec<PackageData> {
127        let yaml_content = match read_yaml_file(path) {
128            Ok(content) => content,
129            Err(e) => {
130                warn!("Failed to read pubspec.lock at {:?}: {}", path, e);
131                let mut package_data =
132                    default_package_data_with_type(PubspecLockParser::PACKAGE_TYPE.as_str());
133                package_data.datasource_id = Some(DatasourceId::PubspecLock);
134                return vec![package_data];
135            }
136        };
137
138        vec![parse_pubspec_lock(&yaml_content)]
139    }
140
141    fn is_match(path: &Path) -> bool {
142        path.file_name()
143            .and_then(|name| name.to_str())
144            .is_some_and(|name| {
145                name == "pubspec.lock"
146                    || name.ends_with("-pubspec.lock")
147                    || name.ends_with(".pubspec.lock")
148            })
149    }
150}
151
152fn read_yaml_file(path: &Path) -> Result<Value, String> {
153    let content =
154        read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))?;
155    yaml_serde::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))
156}
157
158fn parse_pubspec_yaml(yaml_content: &Value) -> PackageData {
159    let name = extract_string_field(yaml_content, FIELD_NAME).map(truncate_field);
160    let version = extract_string_field(yaml_content, FIELD_VERSION).map(truncate_field);
161    let description = extract_description_field(yaml_content).map(truncate_field);
162    let homepage_url = extract_string_field(yaml_content, FIELD_HOMEPAGE).map(truncate_field);
163    let raw_license = extract_string_field(yaml_content, FIELD_LICENSE).map(truncate_field);
164    let vcs_url = extract_string_field(yaml_content, FIELD_REPOSITORY).map(truncate_field);
165    let bug_tracking_url =
166        extract_string_field(yaml_content, FIELD_ISSUE_TRACKER).map(truncate_field);
167    let archive_url = extract_string_field(yaml_content, FIELD_ARCHIVE_URL).map(truncate_field);
168
169    let parties = extract_authors(yaml_content);
170
171    let declared_license_expression = None;
172    let declared_license_expression_spdx = None;
173    let license_detections = Vec::new();
174
175    let dependencies = [
176        collect_dependencies(
177            yaml_content,
178            FIELD_DEPENDENCIES,
179            Some("dependencies"),
180            true,
181            false,
182        ),
183        collect_dependencies(
184            yaml_content,
185            FIELD_DEV_DEPENDENCIES,
186            Some("dev_dependencies"),
187            false,
188            true,
189        ),
190        collect_dependencies(
191            yaml_content,
192            FIELD_DEPENDENCY_OVERRIDES,
193            Some("dependency_overrides"),
194            true,
195            false,
196        ),
197        collect_dependencies(
198            yaml_content,
199            FIELD_ENVIRONMENT,
200            Some("environment"),
201            true,
202            false,
203        ),
204    ]
205    .concat();
206
207    let extra_data = build_extra_data(yaml_content);
208    let keywords = extract_string_list_field(yaml_content, FIELD_TOPICS);
209
210    let purl = name
211        .as_ref()
212        .and_then(|name| build_purl(name, version.as_deref()))
213        .map(truncate_field);
214
215    let (api_data_url, repository_homepage_url, repository_download_url) =
216        if let (Some(name_val), Some(version_val)) = (&name, &version) {
217            (
218                Some(truncate_field(format!(
219                    "https://pub.dev/api/packages/{}/versions/{}",
220                    name_val, version_val
221                ))),
222                Some(truncate_field(format!(
223                    "https://pub.dev/packages/{}/versions/{}",
224                    name_val, version_val
225                ))),
226                Some(truncate_field(format!(
227                    "https://pub.dartlang.org/packages/{}/versions/{}.tar.gz",
228                    name_val, version_val
229                ))),
230            )
231        } else {
232            (None, None, None)
233        };
234
235    let download_url = archive_url.or_else(|| repository_download_url.clone());
236
237    PackageData {
238        package_type: Some(PubspecYamlParser::PACKAGE_TYPE),
239        namespace: None,
240        name,
241        version,
242        qualifiers: None,
243        subpath: None,
244        primary_language: Some("dart".to_string()),
245        description,
246        release_date: None,
247        parties,
248        keywords,
249        homepage_url,
250        download_url,
251        size: None,
252        sha1: None,
253        md5: None,
254        sha256: None,
255        sha512: None,
256        bug_tracking_url,
257        code_view_url: None,
258        vcs_url,
259        copyright: None,
260        holder: None,
261        declared_license_expression,
262        declared_license_expression_spdx,
263        license_detections,
264        other_license_expression: None,
265        other_license_expression_spdx: None,
266        other_license_detections: Vec::new(),
267        extracted_license_statement: raw_license,
268        notice_text: None,
269        source_packages: Vec::new(),
270        file_references: Vec::new(),
271        is_private: yaml_content
272            .get(FIELD_PUBLISH_TO)
273            .and_then(Value::as_str)
274            .is_some_and(|value| value.trim() == "none"),
275        is_virtual: false,
276        extra_data,
277        dependencies,
278        repository_homepage_url,
279        repository_download_url,
280        api_data_url,
281        datasource_id: Some(DatasourceId::PubspecYaml),
282        purl,
283    }
284}
285
286fn parse_pubspec_lock(yaml_content: &Value) -> PackageData {
287    let dependencies = extract_lock_dependencies(yaml_content);
288
289    let mut package_data = default_package_data_with_type(PubspecLockParser::PACKAGE_TYPE.as_str());
290    package_data.dependencies = dependencies;
291    package_data.datasource_id = Some(DatasourceId::PubspecLock);
292    package_data
293}
294
295fn extract_lock_dependencies(lock_data: &Value) -> Vec<Dependency> {
296    let mut dependencies = Vec::new();
297
298    if let Some(sdks) = lock_data.get(FIELD_SDKS).and_then(Value::as_mapping) {
299        let sdks_limit = capped_iteration_limit(sdks.len(), "pubspec.lock SDKs");
300        for (name_value, version_value) in sdks.iter().take(sdks_limit) {
301            if let (Some(name), Some(version_str)) = (name_value.as_str(), version_value.as_str()) {
302                let purl = build_dependency_purl(name, None).map(truncate_field);
303                dependencies.push(Dependency {
304                    purl,
305                    extracted_requirement: Some(truncate_field(version_str.to_string())),
306                    scope: Some("sdk".to_string()),
307                    is_runtime: Some(true),
308                    is_optional: Some(false),
309                    is_pinned: Some(false),
310                    is_direct: Some(true),
311                    resolved_package: None,
312                    extra_data: None,
313                });
314            }
315        }
316    } else if let Some(version_str) = lock_data.get(FIELD_SDK).and_then(Value::as_str) {
317        let purl = build_dependency_purl("dart", None).map(truncate_field);
318        dependencies.push(Dependency {
319            purl,
320            extracted_requirement: Some(truncate_field(version_str.to_string())),
321            scope: Some("sdk".to_string()),
322            is_runtime: Some(true),
323            is_optional: Some(false),
324            is_pinned: Some(false),
325            is_direct: Some(true),
326            resolved_package: None,
327            extra_data: None,
328        });
329    }
330
331    let Some(packages) = lock_data.get(FIELD_PACKAGES).and_then(Value::as_mapping) else {
332        return dependencies;
333    };
334
335    let runtime_reachable =
336        reachable_lock_packages(packages, &["direct main", "direct overridden"]);
337    let dev_only_reachable = reachable_lock_packages(packages, &["direct dev"]);
338
339    let packages_limit = capped_iteration_limit(packages.len(), "pubspec.lock packages");
340    for (name_value, details_value) in packages.iter().take(packages_limit) {
341        let name = match name_value.as_str() {
342            Some(value) => value,
343            None => continue,
344        };
345        let Some(details) = details_value.as_mapping() else {
346            continue;
347        };
348
349        let version = mapping_get(details, FIELD_VERSION)
350            .and_then(Value::as_str)
351            .map(|value| truncate_field(value.to_string()));
352        let dependency_kind = mapping_get(details, FIELD_DEPENDENCY)
353            .and_then(Value::as_str)
354            .map(|value| truncate_field(value.to_string()));
355        let (is_runtime, is_optional, is_direct) = classify_lock_dependency(
356            name,
357            dependency_kind.as_deref(),
358            &runtime_reachable,
359            &dev_only_reachable,
360        );
361
362        let is_pinned = version
363            .as_ref()
364            .is_some_and(|value| !value.trim().is_empty());
365
366        let purl = build_dependency_purl(name, version.as_deref()).map(truncate_field);
367        let sha256 = extract_sha256(details).and_then(|h| Sha256Digest::from_hex(&h).ok());
368        let resolved_dependencies = extract_lock_package_dependencies(details);
369        let resolved_package = build_resolved_package(
370            name,
371            &version,
372            sha256,
373            extract_lock_descriptor_extra_data(details),
374            resolved_dependencies,
375        );
376
377        dependencies.push(Dependency {
378            purl,
379            extracted_requirement: version.clone().map(truncate_field),
380            scope: dependency_kind,
381            is_runtime: Some(is_runtime),
382            is_optional: Some(is_optional),
383            is_pinned: Some(is_pinned),
384            is_direct: Some(is_direct),
385            resolved_package: Some(Box::new(resolved_package)),
386            extra_data: extract_lock_descriptor_extra_data(details),
387        });
388    }
389
390    dependencies
391}
392
393fn extract_lock_package_dependencies(details: &Mapping) -> Vec<Dependency> {
394    let mut dependencies = Vec::new();
395
396    let Some(dep_map) = mapping_get(details, FIELD_DEPENDENCIES).and_then(Value::as_mapping) else {
397        return dependencies;
398    };
399
400    let dep_limit = capped_iteration_limit(dep_map.len(), "pubspec.lock package dependencies");
401    for (name_value, requirement_value) in dep_map.iter().take(dep_limit) {
402        let name = match name_value.as_str() {
403            Some(value) => value,
404            None => continue,
405        };
406
407        let requirement = match dependency_requirement_from_value(requirement_value) {
408            Some(value) => value,
409            None => continue,
410        };
411        let is_pinned = is_pubspec_version_pinned(&requirement);
412        let purl = if is_pinned {
413            build_dependency_purl(name, Some(requirement.as_str()))
414        } else {
415            build_dependency_purl(name, None)
416        };
417
418        dependencies.push(Dependency {
419            purl: purl.map(truncate_field),
420            extracted_requirement: Some(truncate_field(requirement)),
421            scope: Some(FIELD_DEPENDENCIES.to_string()),
422            is_runtime: Some(true),
423            is_optional: Some(false),
424            is_pinned: Some(is_pinned),
425            is_direct: Some(false),
426            resolved_package: None,
427            extra_data: None,
428        });
429    }
430
431    dependencies
432}
433
434fn extract_sha256(details: &Mapping) -> Option<String> {
435    let direct = mapping_get(details, FIELD_SHA256)
436        .and_then(Value::as_str)
437        .map(|value| value.to_string());
438
439    if direct.is_some() {
440        return direct;
441    }
442
443    mapping_get(details, FIELD_DESCRIPTION)
444        .and_then(Value::as_mapping)
445        .and_then(|desc_map| mapping_get(desc_map, FIELD_SHA256))
446        .and_then(Value::as_str)
447        .map(|value| value.to_string())
448}
449
450fn build_resolved_package(
451    name: &str,
452    version: &Option<String>,
453    sha256: Option<Sha256Digest>,
454    extra_data: Option<HashMap<String, serde_json::Value>>,
455    dependencies: Vec<Dependency>,
456) -> ResolvedPackage {
457    ResolvedPackage {
458        primary_language: Some("dart".to_string()),
459        download_url: None,
460        sha1: None,
461        sha256,
462        sha512: None,
463        md5: None,
464        is_virtual: true,
465        extra_data,
466        dependencies,
467        repository_homepage_url: None,
468        repository_download_url: None,
469        api_data_url: None,
470        datasource_id: None,
471        purl: None,
472        ..ResolvedPackage::new(
473            PubspecLockParser::PACKAGE_TYPE,
474            String::new(),
475            truncate_field(name.to_string()),
476            truncate_field(version.clone().unwrap_or_default()),
477        )
478    }
479}
480
481fn collect_dependencies(
482    yaml_content: &Value,
483    field: &str,
484    scope: Option<&str>,
485    is_runtime: bool,
486    is_optional: bool,
487) -> Vec<Dependency> {
488    let mut dependencies = Vec::new();
489
490    let Some(dep_map) = yaml_content.get(field).and_then(Value::as_mapping) else {
491        return dependencies;
492    };
493
494    let dep_limit = capped_iteration_limit(dep_map.len(), "pubspec.yaml dependencies");
495    for (name_value, requirement_value) in dep_map.iter().take(dep_limit) {
496        let name = match name_value.as_str() {
497            Some(value) => value,
498            None => continue,
499        };
500        let requirement = dependency_requirement_from_value(requirement_value).map(truncate_field);
501        let is_pinned = requirement
502            .as_deref()
503            .is_some_and(is_pubspec_version_pinned);
504        let purl = if is_pinned {
505            build_dependency_purl(name, requirement.as_deref())
506        } else {
507            build_dependency_purl(name, None)
508        };
509
510        dependencies.push(Dependency {
511            purl: purl.map(truncate_field),
512            extracted_requirement: requirement,
513            scope: scope.map(|value| value.to_string()),
514            is_runtime: Some(is_runtime),
515            is_optional: Some(is_optional),
516            is_pinned: Some(is_pinned),
517            is_direct: Some(true),
518            resolved_package: None,
519            extra_data: extract_manifest_dependency_extra_data(requirement_value),
520        });
521    }
522
523    dependencies
524}
525
526fn dependency_requirement_from_value(value: &Value) -> Option<String> {
527    if let Some(value) = value.as_str() {
528        let trimmed = value.trim();
529        if trimmed.is_empty() {
530            return None;
531        }
532        return Some(trimmed.to_string());
533    }
534
535    if let Some(value) = value.as_i64() {
536        return Some(value.to_string());
537    }
538
539    if let Some(value) = value.as_f64() {
540        return Some(value.to_string());
541    }
542
543    if let Some(map) = value.as_mapping() {
544        return format_dependency_mapping(map, &mut RecursionGuard::depth_only());
545    }
546
547    None
548}
549
550fn format_dependency_mapping(map: &Mapping, guard: &mut RecursionGuard<()>) -> Option<String> {
551    if guard.descend() {
552        warn!("Recursion depth exceeded in format_dependency_mapping");
553        return None;
554    }
555
556    let mut parts = Vec::new();
557
558    let map_limit = capped_iteration_limit(map.len(), "pubspec dependency mapping");
559    for (key, value) in map.iter().take(map_limit) {
560        let Some(key_str) = key.as_str() else {
561            continue;
562        };
563
564        let value_str = if let Some(value) = value.as_str() {
565            value.to_string()
566        } else if let Some(value) = value.as_i64() {
567            value.to_string()
568        } else if let Some(value) = value.as_f64() {
569            value.to_string()
570        } else if let Some(nested) = value.as_mapping() {
571            format_dependency_mapping(nested, guard)?
572        } else {
573            continue;
574        };
575
576        parts.push(format!("{}: {}", key_str, value_str));
577    }
578
579    guard.ascend();
580
581    if parts.is_empty() {
582        None
583    } else {
584        Some(parts.join(", "))
585    }
586}
587
588fn is_pubspec_version_pinned(version: &str) -> bool {
589    let trimmed = version.trim();
590    if trimmed.is_empty() {
591        return false;
592    }
593
594    trimmed
595        .chars()
596        .all(|character| character.is_ascii_digit() || character == '.')
597}
598
599fn build_purl(name: &str, version: Option<&str>) -> Option<String> {
600    build_purl_with_type(PackageType::Pub.as_str(), name, version)
601}
602
603fn build_dependency_purl(name: &str, version: Option<&str>) -> Option<String> {
604    build_purl_with_type(PackageType::Pub.as_str(), name, version)
605}
606
607fn build_purl_with_type(package_type: &str, name: &str, version: Option<&str>) -> Option<String> {
608    let mut package_url = match PackageUrl::new(package_type, name) {
609        Ok(purl) => purl,
610        Err(e) => {
611            warn!(
612                "Failed to create PackageUrl for {} dependency '{}': {}",
613                package_type, name, e
614            );
615            return None;
616        }
617    };
618
619    if let Some(version) = version
620        && let Err(e) = package_url.with_version(version)
621    {
622        warn!(
623            "Failed to set version '{}' for {} dependency '{}': {}",
624            version, package_type, name, e
625        );
626        return None;
627    }
628
629    Some(package_url.to_string())
630}
631
632fn extract_string_field(yaml_content: &Value, field: &str) -> Option<String> {
633    yaml_content
634        .get(field)
635        .and_then(Value::as_str)
636        .map(|value| value.trim().to_string())
637        .filter(|value| !value.is_empty())
638}
639
640fn extract_description_field(yaml_content: &Value) -> Option<String> {
641    // For description fields, preserve trailing newlines as they are semantically
642    // significant in YAML folded/literal scalars (> or |)
643    yaml_content
644        .get(FIELD_DESCRIPTION)
645        .and_then(Value::as_str)
646        .and_then(|value| {
647            // Only trim leading whitespace, preserve trailing newlines
648            let trimmed = value.trim_start();
649            if trimmed.is_empty() {
650                None
651            } else {
652                Some(trimmed.to_string())
653            }
654        })
655}
656
657fn mapping_get<'a>(map: &'a Mapping, key: &str) -> Option<&'a Value> {
658    map.get(Value::String(key.to_string()))
659}
660
661fn default_package_data() -> PackageData {
662    default_package_data_with_type(PubspecYamlParser::PACKAGE_TYPE.as_str())
663}
664
665fn default_package_data_with_type(package_type: &str) -> PackageData {
666    PackageData {
667        package_type: package_type.parse::<PackageType>().ok(),
668        primary_language: Some("dart".to_string()),
669        ..Default::default()
670    }
671}
672
673fn extract_authors(yaml_content: &Value) -> Vec<crate::models::Party> {
674    use crate::models::Party;
675    let mut parties = Vec::new();
676
677    if let Some(author) = extract_string_field(yaml_content, FIELD_AUTHOR).map(truncate_field) {
678        parties.push(Party {
679            r#type: None,
680            role: Some("author".to_string()),
681            name: Some(author),
682            email: None,
683            url: None,
684            organization: None,
685            organization_url: None,
686            timezone: None,
687        });
688    }
689
690    if let Some(authors_value) = yaml_content.get(FIELD_AUTHORS)
691        && let Some(authors_array) = authors_value.as_sequence()
692    {
693        let authors_limit = capped_iteration_limit(authors_array.len(), "pubspec.yaml authors");
694        for author_value in authors_array.iter().take(authors_limit) {
695            if let Some(author_str) = author_value.as_str() {
696                parties.push(Party {
697                    r#type: None,
698                    role: Some("author".to_string()),
699                    name: Some(truncate_field(author_str.to_string())),
700                    email: None,
701                    url: None,
702                    organization: None,
703                    organization_url: None,
704                    timezone: None,
705                });
706            }
707        }
708    }
709
710    parties
711}
712
713fn build_extra_data(
714    yaml_content: &Value,
715) -> Option<std::collections::HashMap<String, serde_json::Value>> {
716    use std::collections::HashMap;
717    let mut extra_data = HashMap::new();
718
719    if let Some(issue_tracker) = extract_string_field(yaml_content, FIELD_ISSUE_TRACKER) {
720        extra_data.insert(
721            FIELD_ISSUE_TRACKER.to_string(),
722            serde_json::Value::String(issue_tracker),
723        );
724    }
725
726    if let Some(documentation) = extract_string_field(yaml_content, FIELD_DOCUMENTATION) {
727        extra_data.insert(
728            FIELD_DOCUMENTATION.to_string(),
729            serde_json::Value::String(documentation),
730        );
731    }
732
733    if let Some(executables) = yaml_content.get(FIELD_EXECUTABLES) {
734        // Convert yaml_serde::Value to serde_json::Value
735        if let Ok(json_value) = serde_json::to_value(executables) {
736            extra_data.insert(FIELD_EXECUTABLES.to_string(), json_value);
737        }
738    }
739
740    if let Some(publish_to) = extract_string_field(yaml_content, FIELD_PUBLISH_TO) {
741        extra_data.insert(
742            FIELD_PUBLISH_TO.to_string(),
743            serde_json::Value::String(publish_to),
744        );
745    }
746
747    for field in [
748        FIELD_PLATFORMS,
749        FIELD_FUNDING,
750        FIELD_FALSE_SECRETS,
751        FIELD_SCREENSHOTS,
752        FIELD_TOPICS,
753        FIELD_IGNORED_ADVISORIES,
754    ] {
755        if let Some(value) = yaml_content.get(field)
756            && let Ok(json_value) = serde_json::to_value(value)
757        {
758            extra_data.insert(field.to_string(), json_value);
759        }
760    }
761
762    if extra_data.is_empty() {
763        None
764    } else {
765        Some(extra_data)
766    }
767}
768
769fn extract_string_list_field(yaml_content: &Value, field: &str) -> Vec<String> {
770    yaml_content
771        .get(field)
772        .and_then(Value::as_sequence)
773        .into_iter()
774        .flatten()
775        .filter_map(Value::as_str)
776        .map(str::trim)
777        .filter(|value| !value.is_empty())
778        .map(|value| truncate_field(value.to_string()))
779        .collect()
780}
781
782fn extract_manifest_dependency_extra_data(
783    requirement_value: &Value,
784) -> Option<HashMap<String, serde_json::Value>> {
785    requirement_value
786        .as_mapping()
787        .and_then(|map| serde_json::to_value(map).ok())
788        .and_then(|json| json.as_object().cloned())
789        .map(|map| map.into_iter().collect())
790}
791
792fn extract_lock_descriptor_extra_data(
793    details: &Mapping,
794) -> Option<HashMap<String, serde_json::Value>> {
795    let mut extra = HashMap::new();
796
797    if let Some(source) = mapping_get(details, "source").and_then(Value::as_str) {
798        extra.insert(
799            "source".to_string(),
800            serde_json::Value::String(source.to_string()),
801        );
802    }
803
804    if let Some(description) = mapping_get(details, FIELD_DESCRIPTION)
805        && let Ok(json_value) = serde_json::to_value(description)
806    {
807        extra.insert("description".to_string(), json_value);
808    }
809
810    if let Some(kind) = mapping_get(details, FIELD_DEPENDENCY).and_then(Value::as_str) {
811        extra.insert(
812            FIELD_DEPENDENCY.to_string(),
813            serde_json::Value::String(kind.to_string()),
814        );
815    }
816
817    (!extra.is_empty()).then_some(extra)
818}
819
820fn reachable_lock_packages(packages: &Mapping, roots: &[&str]) -> HashSet<String> {
821    let mut reachable = HashSet::new();
822    let mut queue = VecDeque::new();
823    let mut iterations = 0usize;
824
825    let roots_limit = capped_iteration_limit(packages.len(), "pubspec.lock reachability roots");
826    for (name_value, details_value) in packages.iter().take(roots_limit) {
827        let Some(name) = name_value.as_str() else {
828            continue;
829        };
830        let Some(details) = details_value.as_mapping() else {
831            continue;
832        };
833        let kind = mapping_get(details, FIELD_DEPENDENCY).and_then(Value::as_str);
834        if roots.contains(&kind.unwrap_or_default()) {
835            queue.push_back(truncate_field(name.to_string()));
836        }
837    }
838
839    while let Some(current) = queue.pop_front() {
840        if iterations >= MAX_ITERATION_COUNT {
841            warn!("Iteration count exceeded in reachable_lock_packages BFS");
842            break;
843        }
844        iterations += 1;
845
846        if !reachable.insert(current.clone()) {
847            continue;
848        }
849
850        let Some(details_value) = packages.get(Value::String(current.clone())) else {
851            continue;
852        };
853        let Some(details) = details_value.as_mapping() else {
854            continue;
855        };
856        let Some(dep_map) = mapping_get(details, FIELD_DEPENDENCIES).and_then(Value::as_mapping)
857        else {
858            continue;
859        };
860
861        let dep_keys_limit =
862            capped_iteration_limit(dep_map.len(), "pubspec.lock reachability edges");
863        for dep_name in dep_map
864            .keys()
865            .filter_map(Value::as_str)
866            .take(dep_keys_limit)
867        {
868            queue.push_back(truncate_field(dep_name.to_string()));
869        }
870    }
871
872    reachable
873}
874
875fn classify_lock_dependency(
876    name: &str,
877    dependency_kind: Option<&str>,
878    runtime_reachable: &HashSet<String>,
879    dev_only_reachable: &HashSet<String>,
880) -> (bool, bool, bool) {
881    match dependency_kind {
882        Some("direct main") | Some("direct overridden") => (true, false, true),
883        Some("direct dev") => (false, true, true),
884        Some("transitive") => {
885            if runtime_reachable.contains(name) {
886                (true, false, false)
887            } else if dev_only_reachable.contains(name) {
888                (false, true, false)
889            } else {
890                (true, false, false)
891            }
892        }
893        _ => (true, false, true),
894    }
895}
896
897#[cfg(test)]
898mod is_match_tests {
899    use super::{PubspecLockParser, PubspecYamlParser};
900    use crate::parsers::PackageParser;
901    use std::path::PathBuf;
902
903    #[test]
904    fn test_pubspec_yaml_parser_matches_suffixed_filenames() {
905        assert!(PubspecYamlParser::is_match(&PathBuf::from("pubspec.yaml")));
906        assert!(PubspecYamlParser::is_match(&PathBuf::from(
907            "simple-pubspec.yaml"
908        )));
909        assert!(PubspecYamlParser::is_match(&PathBuf::from(
910            "simple.pubspec.yaml"
911        )));
912        assert!(!PubspecYamlParser::is_match(&PathBuf::from("pubspec.yml")));
913    }
914
915    #[test]
916    fn test_pubspec_lock_parser_matches_suffixed_filenames() {
917        assert!(PubspecLockParser::is_match(&PathBuf::from("pubspec.lock")));
918        assert!(PubspecLockParser::is_match(&PathBuf::from(
919            "dart-pubspec.lock"
920        )));
921        assert!(PubspecLockParser::is_match(&PathBuf::from(
922            "dart.pubspec.lock"
923        )));
924        assert!(!PubspecLockParser::is_match(&PathBuf::from(
925            "pubspec.lock.bak"
926        )));
927    }
928}