Skip to main content

provenant/parsers/
dart.rs

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