Skip to main content

provenant/parsers/
dart.rs

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