Skip to main content

provenant/parsers/
dart.rs

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