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