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