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 `serde_yaml` crate
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::fs;
24use std::path::Path;
25
26use log::warn;
27use packageurl::PackageUrl;
28use serde_yaml::{Mapping, Value};
29
30use crate::models::{DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage};
31
32use super::PackageParser;
33
34const FIELD_NAME: &str = "name";
35const FIELD_VERSION: &str = "version";
36const FIELD_DESCRIPTION: &str = "description";
37const FIELD_HOMEPAGE: &str = "homepage";
38const FIELD_LICENSE: &str = "license";
39const FIELD_REPOSITORY: &str = "repository";
40const FIELD_AUTHOR: &str = "author";
41const FIELD_AUTHORS: &str = "authors";
42const FIELD_DEPENDENCIES: &str = "dependencies";
43const FIELD_DEV_DEPENDENCIES: &str = "dev_dependencies";
44const FIELD_DEPENDENCY_OVERRIDES: &str = "dependency_overrides";
45const FIELD_ENVIRONMENT: &str = "environment";
46const FIELD_ISSUE_TRACKER: &str = "issue_tracker";
47const FIELD_DOCUMENTATION: &str = "documentation";
48const FIELD_EXECUTABLES: &str = "executables";
49const FIELD_PUBLISH_TO: &str = "publish_to";
50const FIELD_PACKAGES: &str = "packages";
51const FIELD_SDKS: &str = "sdks";
52const FIELD_DEPENDENCY: &str = "dependency";
53const FIELD_SHA256: &str = "sha256";
54
55/// Dart pubspec.yaml manifest parser.
56pub struct PubspecYamlParser;
57
58impl PackageParser for PubspecYamlParser {
59    const PACKAGE_TYPE: PackageType = PackageType::Dart;
60
61    fn extract_packages(path: &Path) -> Vec<PackageData> {
62        let yaml_content = match read_yaml_file(path) {
63            Ok(content) => content,
64            Err(e) => {
65                warn!("Failed to read pubspec.yaml at {:?}: {}", path, e);
66                return vec![default_package_data()];
67            }
68        };
69
70        vec![parse_pubspec_yaml(&yaml_content)]
71    }
72
73    fn is_match(path: &Path) -> bool {
74        path.file_name().is_some_and(|name| name == "pubspec.yaml")
75    }
76}
77
78/// Dart pubspec.lock lockfile parser.
79pub struct PubspecLockParser;
80
81impl PackageParser for PubspecLockParser {
82    const PACKAGE_TYPE: PackageType = PackageType::Pubspec;
83
84    fn extract_packages(path: &Path) -> Vec<PackageData> {
85        let yaml_content = match read_yaml_file(path) {
86            Ok(content) => content,
87            Err(e) => {
88                warn!("Failed to read pubspec.lock at {:?}: {}", path, e);
89                return vec![default_package_data()];
90            }
91        };
92
93        vec![parse_pubspec_lock(&yaml_content)]
94    }
95
96    fn is_match(path: &Path) -> bool {
97        path.file_name().is_some_and(|name| name == "pubspec.lock")
98    }
99}
100
101fn read_yaml_file(path: &Path) -> Result<Value, String> {
102    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
103    serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))
104}
105
106fn parse_pubspec_yaml(yaml_content: &Value) -> PackageData {
107    let name = extract_string_field(yaml_content, FIELD_NAME);
108    let version = extract_string_field(yaml_content, FIELD_VERSION);
109    let description = extract_description_field(yaml_content);
110    let homepage_url = extract_string_field(yaml_content, FIELD_HOMEPAGE);
111    let raw_license = extract_string_field(yaml_content, FIELD_LICENSE);
112    let vcs_url = extract_string_field(yaml_content, FIELD_REPOSITORY);
113
114    let parties = extract_authors(yaml_content);
115
116    // Extract license statement only - detection happens in separate engine
117    let declared_license_expression = None;
118    let declared_license_expression_spdx = None;
119    let license_detections = Vec::new();
120
121    let dependencies = [
122        collect_dependencies(
123            yaml_content,
124            FIELD_DEPENDENCIES,
125            Some("dependencies"),
126            true,
127            false,
128        ),
129        collect_dependencies(
130            yaml_content,
131            FIELD_DEV_DEPENDENCIES,
132            Some("dev_dependencies"),
133            false,
134            true,
135        ),
136        collect_dependencies(
137            yaml_content,
138            FIELD_DEPENDENCY_OVERRIDES,
139            Some("dependency_overrides"),
140            true,
141            false,
142        ),
143        collect_dependencies(
144            yaml_content,
145            FIELD_ENVIRONMENT,
146            Some("environment"),
147            true,
148            false,
149        ),
150    ]
151    .concat();
152
153    let extra_data = build_extra_data(yaml_content);
154
155    let purl = name
156        .as_ref()
157        .and_then(|name| build_purl(name, version.as_deref()));
158
159    let (api_data_url, repository_homepage_url, repository_download_url) =
160        if let (Some(name_val), Some(version_val)) = (&name, &version) {
161            (
162                Some(format!(
163                    "https://pub.dev/api/packages/{}/versions/{}",
164                    name_val, version_val
165                )),
166                Some(format!(
167                    "https://pub.dev/packages/{}/versions/{}",
168                    name_val, version_val
169                )),
170                Some(format!(
171                    "https://pub.dartlang.org/packages/{}/versions/{}.tar.gz",
172                    name_val, version_val
173                )),
174            )
175        } else {
176            (None, None, None)
177        };
178
179    let download_url = repository_download_url.clone();
180
181    PackageData {
182        package_type: Some(PubspecYamlParser::PACKAGE_TYPE),
183        namespace: None,
184        name,
185        version,
186        qualifiers: None,
187        subpath: None,
188        primary_language: Some("dart".to_string()),
189        description,
190        release_date: None,
191        parties,
192        keywords: Vec::new(),
193        homepage_url,
194        download_url,
195        size: None,
196        sha1: None,
197        md5: None,
198        sha256: None,
199        sha512: None,
200        bug_tracking_url: None,
201        code_view_url: None,
202        vcs_url,
203        copyright: None,
204        holder: None,
205        declared_license_expression,
206        declared_license_expression_spdx,
207        license_detections,
208        other_license_expression: None,
209        other_license_expression_spdx: None,
210        other_license_detections: Vec::new(),
211        extracted_license_statement: raw_license,
212        notice_text: None,
213        source_packages: Vec::new(),
214        file_references: Vec::new(),
215        is_private: false,
216        is_virtual: false,
217        extra_data,
218        dependencies,
219        repository_homepage_url,
220        repository_download_url,
221        api_data_url,
222        datasource_id: Some(DatasourceId::PubspecYaml),
223        purl,
224    }
225}
226
227fn parse_pubspec_lock(yaml_content: &Value) -> PackageData {
228    let dependencies = extract_lock_dependencies(yaml_content);
229
230    let mut package_data = default_package_data_with_type(PubspecLockParser::PACKAGE_TYPE.as_str());
231    package_data.dependencies = dependencies;
232    package_data.datasource_id = Some(DatasourceId::PubspecLock);
233    package_data
234}
235
236fn extract_lock_dependencies(lock_data: &Value) -> Vec<Dependency> {
237    let mut dependencies = Vec::new();
238
239    if let Some(sdks) = lock_data.get(FIELD_SDKS).and_then(Value::as_mapping) {
240        for (name_value, version_value) in sdks {
241            if let (Some(name), Some(version_str)) = (name_value.as_str(), version_value.as_str()) {
242                let purl = build_dependency_purl(name, None);
243                dependencies.push(Dependency {
244                    purl,
245                    extracted_requirement: Some(version_str.to_string()),
246                    scope: Some("sdk".to_string()),
247                    is_runtime: Some(true),
248                    is_optional: Some(false),
249                    is_pinned: Some(false),
250                    is_direct: Some(true),
251                    resolved_package: None,
252                    extra_data: None,
253                });
254            }
255        }
256    }
257
258    let Some(packages) = lock_data.get(FIELD_PACKAGES).and_then(Value::as_mapping) else {
259        return dependencies;
260    };
261
262    for (name_value, details_value) in packages {
263        let name = match name_value.as_str() {
264            Some(value) => value,
265            None => continue,
266        };
267        let Some(details) = details_value.as_mapping() else {
268            continue;
269        };
270
271        let version = mapping_get(details, FIELD_VERSION)
272            .and_then(Value::as_str)
273            .map(|value| value.to_string());
274        let dependency_kind = mapping_get(details, FIELD_DEPENDENCY)
275            .and_then(Value::as_str)
276            .map(|value| value.to_string());
277
278        let is_runtime = dependency_kind.as_deref() != Some("direct dev");
279
280        let is_pinned = version
281            .as_ref()
282            .is_some_and(|value| !value.trim().is_empty());
283
284        let purl = build_dependency_purl(name, version.as_deref());
285        let sha256 = extract_sha256(details);
286        let resolved_dependencies = extract_lock_package_dependencies(details);
287        let resolved_package =
288            build_resolved_package(name, &version, sha256, resolved_dependencies);
289
290        dependencies.push(Dependency {
291            purl,
292            extracted_requirement: version.clone(),
293            scope: dependency_kind,
294            is_runtime: Some(is_runtime),
295            is_optional: Some(false),
296            is_pinned: Some(is_pinned),
297            is_direct: Some(true),
298            resolved_package: Some(Box::new(resolved_package)),
299            extra_data: None,
300        });
301    }
302
303    dependencies
304}
305
306fn extract_lock_package_dependencies(details: &Mapping) -> Vec<Dependency> {
307    let mut dependencies = Vec::new();
308
309    let Some(dep_map) = mapping_get(details, FIELD_DEPENDENCIES).and_then(Value::as_mapping) else {
310        return dependencies;
311    };
312
313    for (name_value, requirement_value) in dep_map {
314        let name = match name_value.as_str() {
315            Some(value) => value,
316            None => continue,
317        };
318
319        let requirement = match dependency_requirement_from_value(requirement_value) {
320            Some(value) => value,
321            None => continue,
322        };
323        let is_pinned = is_pubspec_version_pinned(&requirement);
324        let purl = if is_pinned {
325            build_dependency_purl(name, Some(requirement.as_str()))
326        } else {
327            build_dependency_purl(name, None)
328        };
329
330        dependencies.push(Dependency {
331            purl,
332            extracted_requirement: Some(requirement),
333            scope: Some(FIELD_DEPENDENCIES.to_string()),
334            is_runtime: Some(true),
335            is_optional: Some(false),
336            is_pinned: Some(is_pinned),
337            is_direct: Some(false),
338            resolved_package: None,
339            extra_data: None,
340        });
341    }
342
343    dependencies
344}
345
346fn extract_sha256(details: &Mapping) -> Option<String> {
347    let direct = mapping_get(details, FIELD_SHA256)
348        .and_then(Value::as_str)
349        .map(|value| value.to_string());
350
351    if direct.is_some() {
352        return direct;
353    }
354
355    mapping_get(details, FIELD_DESCRIPTION)
356        .and_then(Value::as_mapping)
357        .and_then(|desc_map| mapping_get(desc_map, FIELD_SHA256))
358        .and_then(Value::as_str)
359        .map(|value| value.to_string())
360}
361
362fn build_resolved_package(
363    name: &str,
364    version: &Option<String>,
365    sha256: Option<String>,
366    dependencies: Vec<Dependency>,
367) -> ResolvedPackage {
368    ResolvedPackage {
369        package_type: PubspecLockParser::PACKAGE_TYPE,
370        namespace: String::new(),
371        name: name.to_string(),
372        version: version.clone().unwrap_or_default(),
373        primary_language: Some("dart".to_string()),
374        download_url: None,
375        sha1: None,
376        sha256,
377        sha512: None,
378        md5: None,
379        is_virtual: true,
380        extra_data: None,
381        dependencies,
382        repository_homepage_url: None,
383        repository_download_url: None,
384        api_data_url: None,
385        datasource_id: None,
386        purl: None,
387    }
388}
389
390fn collect_dependencies(
391    yaml_content: &Value,
392    field: &str,
393    scope: Option<&str>,
394    is_runtime: bool,
395    is_optional: bool,
396) -> Vec<Dependency> {
397    let mut dependencies = Vec::new();
398
399    let Some(dep_map) = yaml_content.get(field).and_then(Value::as_mapping) else {
400        return dependencies;
401    };
402
403    for (name_value, requirement_value) in dep_map {
404        let name = match name_value.as_str() {
405            Some(value) => value,
406            None => continue,
407        };
408        let requirement = match dependency_requirement_from_value(requirement_value) {
409            Some(value) => value,
410            None => continue,
411        };
412
413        let is_pinned = is_pubspec_version_pinned(&requirement);
414        let purl = if is_pinned {
415            build_dependency_purl(name, Some(requirement.as_str()))
416        } else {
417            build_dependency_purl(name, None)
418        };
419
420        dependencies.push(Dependency {
421            purl,
422            extracted_requirement: Some(requirement),
423            scope: scope.map(|value| value.to_string()),
424            is_runtime: Some(is_runtime),
425            is_optional: Some(is_optional),
426            is_pinned: Some(is_pinned),
427            is_direct: Some(true),
428            resolved_package: None,
429            extra_data: None,
430        });
431    }
432
433    dependencies
434}
435
436fn dependency_requirement_from_value(value: &Value) -> Option<String> {
437    if let Some(value) = value.as_str() {
438        let trimmed = value.trim();
439        if trimmed.is_empty() {
440            return None;
441        }
442        return Some(trimmed.to_string());
443    }
444
445    if let Some(value) = value.as_i64() {
446        return Some(value.to_string());
447    }
448
449    if let Some(value) = value.as_f64() {
450        return Some(value.to_string());
451    }
452
453    if let Some(map) = value.as_mapping() {
454        return format_dependency_mapping(map);
455    }
456
457    None
458}
459
460fn format_dependency_mapping(map: &Mapping) -> Option<String> {
461    let mut parts = Vec::new();
462
463    for (key, value) in map {
464        let Some(key_str) = key.as_str() else {
465            continue;
466        };
467
468        let value_str = if let Some(value) = value.as_str() {
469            value.to_string()
470        } else if let Some(value) = value.as_i64() {
471            value.to_string()
472        } else if let Some(value) = value.as_f64() {
473            value.to_string()
474        } else {
475            continue;
476        };
477
478        parts.push(format!("{}: {}", key_str, value_str));
479    }
480
481    if parts.is_empty() {
482        None
483    } else {
484        Some(parts.join(", "))
485    }
486}
487
488fn is_pubspec_version_pinned(version: &str) -> bool {
489    let trimmed = version.trim();
490    if trimmed.is_empty() {
491        return false;
492    }
493
494    trimmed
495        .chars()
496        .all(|character| character.is_ascii_digit() || character == '.')
497}
498
499fn build_purl(name: &str, version: Option<&str>) -> Option<String> {
500    build_purl_with_type(PubspecYamlParser::PACKAGE_TYPE.as_str(), name, version)
501}
502
503fn build_dependency_purl(name: &str, version: Option<&str>) -> Option<String> {
504    build_purl_with_type("pubspec", name, version)
505}
506
507fn build_purl_with_type(package_type: &str, name: &str, version: Option<&str>) -> Option<String> {
508    let mut package_url = match PackageUrl::new(package_type, name) {
509        Ok(purl) => purl,
510        Err(e) => {
511            warn!(
512                "Failed to create PackageUrl for {} dependency '{}': {}",
513                package_type, name, e
514            );
515            return None;
516        }
517    };
518
519    if let Some(version) = version
520        && let Err(e) = package_url.with_version(version)
521    {
522        warn!(
523            "Failed to set version '{}' for {} dependency '{}': {}",
524            version, package_type, name, e
525        );
526        return None;
527    }
528
529    Some(package_url.to_string())
530}
531
532fn extract_string_field(yaml_content: &Value, field: &str) -> Option<String> {
533    yaml_content
534        .get(field)
535        .and_then(Value::as_str)
536        .map(|value| value.trim().to_string())
537        .filter(|value| !value.is_empty())
538}
539
540fn extract_description_field(yaml_content: &Value) -> Option<String> {
541    // For description fields, preserve trailing newlines as they are semantically
542    // significant in YAML folded/literal scalars (> or |)
543    yaml_content
544        .get(FIELD_DESCRIPTION)
545        .and_then(Value::as_str)
546        .and_then(|value| {
547            // Only trim leading whitespace, preserve trailing newlines
548            let trimmed = value.trim_start();
549            if trimmed.is_empty() {
550                None
551            } else {
552                Some(trimmed.to_string())
553            }
554        })
555}
556
557fn mapping_get<'a>(map: &'a Mapping, key: &str) -> Option<&'a Value> {
558    map.get(Value::String(key.to_string()))
559}
560
561fn default_package_data() -> PackageData {
562    default_package_data_with_type(PubspecYamlParser::PACKAGE_TYPE.as_str())
563}
564
565fn default_package_data_with_type(package_type: &str) -> PackageData {
566    PackageData {
567        package_type: package_type.parse::<PackageType>().ok(),
568        primary_language: Some("dart".to_string()),
569        ..Default::default()
570    }
571}
572
573fn extract_authors(yaml_content: &Value) -> Vec<crate::models::Party> {
574    use crate::models::Party;
575    let mut parties = Vec::new();
576
577    if let Some(author) = extract_string_field(yaml_content, FIELD_AUTHOR) {
578        parties.push(Party {
579            r#type: None,
580            role: Some("author".to_string()),
581            name: Some(author),
582            email: None,
583            url: None,
584            organization: None,
585            organization_url: None,
586            timezone: None,
587        });
588    }
589
590    if let Some(authors_value) = yaml_content.get(FIELD_AUTHORS)
591        && let Some(authors_array) = authors_value.as_sequence()
592    {
593        for author_value in authors_array {
594            if let Some(author_str) = author_value.as_str() {
595                parties.push(Party {
596                    r#type: None,
597                    role: Some("author".to_string()),
598                    name: Some(author_str.to_string()),
599                    email: None,
600                    url: None,
601                    organization: None,
602                    organization_url: None,
603                    timezone: None,
604                });
605            }
606        }
607    }
608
609    parties
610}
611
612fn build_extra_data(
613    yaml_content: &Value,
614) -> Option<std::collections::HashMap<String, serde_json::Value>> {
615    use std::collections::HashMap;
616    let mut extra_data = HashMap::new();
617
618    if let Some(issue_tracker) = extract_string_field(yaml_content, FIELD_ISSUE_TRACKER) {
619        extra_data.insert(
620            FIELD_ISSUE_TRACKER.to_string(),
621            serde_json::Value::String(issue_tracker),
622        );
623    }
624
625    if let Some(documentation) = extract_string_field(yaml_content, FIELD_DOCUMENTATION) {
626        extra_data.insert(
627            FIELD_DOCUMENTATION.to_string(),
628            serde_json::Value::String(documentation),
629        );
630    }
631
632    if let Some(executables) = yaml_content.get(FIELD_EXECUTABLES) {
633        // Convert serde_yaml::Value to serde_json::Value
634        if let Ok(json_value) = serde_json::to_value(executables) {
635            extra_data.insert(FIELD_EXECUTABLES.to_string(), json_value);
636        }
637    }
638
639    if let Some(publish_to) = extract_string_field(yaml_content, FIELD_PUBLISH_TO) {
640        extra_data.insert(
641            FIELD_PUBLISH_TO.to_string(),
642            serde_json::Value::String(publish_to),
643        );
644    }
645
646    if extra_data.is_empty() {
647        None
648    } else {
649        Some(extra_data)
650    }
651}
652
653crate::register_parser!(
654    "Dart pubspec.yaml manifest",
655    &["**/pubspec.yaml", "**/pubspec.lock"],
656    "pub",
657    "Dart",
658    Some("https://dart.dev/tools/pub/pubspec"),
659);