Skip to main content

provenant/parsers/
julia.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parser for Julia Project.toml and Manifest.toml files.
5//!
6//! Extracts package metadata, dependencies, and license information from
7//! Julia package manager (Pkg.jl) manifest files.
8//!
9//! # Supported Formats
10//! - Project.toml (package metadata)
11//! - Manifest.toml (resolved dependency tree)
12//!
13//! # Key Features
14//! - Dependency extraction with UUID tracking
15//! - `is_pinned` analysis based on Manifest.toml resolved versions
16//! - Package URL (purl) generation
17//! - Compat section version constraint extraction
18//!
19//! # Implementation Notes
20//! - Uses toml crate for parsing
21//! - Julia packages are identified by UUID
22//! - Project.toml `[deps]` lists direct dependencies by name → UUID
23//! - Manifest.toml `[[deps]]` entries contain resolved version + tree SHA
24
25use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party};
26use crate::parser_warn as warn;
27use crate::parsers::utils::{
28    RecursionGuard, capped_iteration_limit, read_file_to_string, truncate_field,
29};
30use packageurl::PackageUrl;
31use std::path::Path;
32use toml::Value;
33
34use super::PackageParser;
35use super::license_normalization::{
36    DeclaredLicenseMatchMetadata, build_declared_license_data, empty_declared_license_data,
37    normalize_spdx_expression,
38};
39use super::metadata::ParserMetadata;
40
41const FIELD_NAME: &str = "name";
42const FIELD_UUID: &str = "uuid";
43const FIELD_VERSION: &str = "version";
44const FIELD_LICENSE: &str = "license";
45const FIELD_AUTHOR: &str = "author";
46const FIELD_AUTHORS: &str = "authors";
47const FIELD_REPOSITORY: &str = "repository";
48const FIELD_DEPS: &str = "deps";
49const FIELD_COMPAT: &str = "compat";
50const FIELD_TARGETS: &str = "targets";
51const FIELD_HOMEPAGE: &str = "homepage";
52
53pub struct JuliaProjectTomlParser;
54
55impl PackageParser for JuliaProjectTomlParser {
56    const PACKAGE_TYPE: PackageType = PackageType::Julia;
57
58    fn metadata() -> Vec<ParserMetadata> {
59        vec![ParserMetadata {
60            description: "Julia Project.toml manifest",
61            file_patterns: &["**/Project.toml"],
62            package_type: "julia",
63            primary_language: "Julia",
64            documentation_url: Some("https://pkgdocs.julialang.org/v1/toml-files/"),
65        }]
66    }
67
68    fn extract_packages(path: &Path) -> Vec<PackageData> {
69        let toml_content = match read_julia_toml(path) {
70            Ok(content) => content,
71            Err(e) => {
72                warn!("Failed to read or parse Project.toml at {:?}: {}", path, e);
73                return vec![default_project_package_data()];
74            }
75        };
76
77        let name = toml_content
78            .get(FIELD_NAME)
79            .and_then(|v| v.as_str())
80            .map(|s| truncate_field(s.to_string()));
81
82        let version = toml_content
83            .get(FIELD_VERSION)
84            .and_then(|v| v.as_str())
85            .map(|s| truncate_field(s.to_string()));
86
87        let raw_license = toml_content
88            .get(FIELD_LICENSE)
89            .and_then(|v| v.as_str())
90            .map(|s| truncate_field(s.to_string()));
91
92        let (declared_license_expression, declared_license_expression_spdx, license_detections) =
93            raw_license
94                .as_deref()
95                .and_then(normalize_spdx_expression)
96                .map(|normalized| {
97                    build_declared_license_data(
98                        normalized,
99                        DeclaredLicenseMatchMetadata::single_line(
100                            raw_license.as_deref().unwrap_or_default(),
101                        ),
102                    )
103                })
104                .unwrap_or_else(empty_declared_license_data);
105
106        let extracted_license_statement = raw_license.clone().map(truncate_field);
107
108        let dependencies = extract_project_dependencies(&toml_content);
109
110        let uuid = toml_content.get(FIELD_UUID).and_then(|v| v.as_str());
111        let purl = create_package_url(&name, &version, uuid);
112
113        let repository_url = toml_content
114            .get(FIELD_REPOSITORY)
115            .and_then(|v| v.as_str())
116            .map(|s| truncate_field(s.to_string()));
117
118        let homepage_url = toml_content
119            .get(FIELD_HOMEPAGE)
120            .and_then(|v| v.as_str())
121            .map(|s| truncate_field(s.to_string()));
122
123        let description = None;
124
125        let extra_data = extract_project_extra_data(&toml_content);
126
127        let is_private = false;
128
129        vec![PackageData {
130            package_type: Some(Self::PACKAGE_TYPE),
131            namespace: None,
132            name,
133            version,
134            qualifiers: None,
135            subpath: None,
136            primary_language: Some("Julia".to_string()),
137            description,
138            release_date: None,
139            parties: extract_parties(&toml_content),
140            keywords: Vec::new(),
141            homepage_url,
142            download_url: None,
143            size: None,
144            sha1: None,
145            md5: None,
146            sha256: None,
147            sha512: None,
148            bug_tracking_url: None,
149            code_view_url: None,
150            vcs_url: repository_url,
151            copyright: None,
152            holder: None,
153            declared_license_expression,
154            declared_license_expression_spdx,
155            license_detections,
156            other_license_expression: None,
157            other_license_expression_spdx: None,
158            other_license_detections: Vec::new(),
159            extracted_license_statement,
160            notice_text: None,
161            source_packages: Vec::new(),
162            file_references: Vec::new(),
163            is_private,
164            is_virtual: false,
165            extra_data,
166            dependencies,
167            repository_homepage_url: None,
168            repository_download_url: None,
169            api_data_url: None,
170            datasource_id: Some(DatasourceId::JuliaProjectToml),
171            purl,
172        }]
173    }
174
175    fn is_match(path: &Path) -> bool {
176        path.file_name()
177            .and_then(|name| name.to_str())
178            .is_some_and(|name| name.eq_ignore_ascii_case("Project.toml"))
179    }
180}
181
182pub struct JuliaManifestTomlParser;
183
184impl PackageParser for JuliaManifestTomlParser {
185    const PACKAGE_TYPE: PackageType = PackageType::Julia;
186
187    fn metadata() -> Vec<ParserMetadata> {
188        vec![ParserMetadata {
189            description: "Julia Manifest.toml resolved dependencies",
190            file_patterns: &["**/Manifest.toml"],
191            package_type: "julia",
192            primary_language: "Julia",
193            documentation_url: Some("https://pkgdocs.julialang.org/v1/toml-files/"),
194        }]
195    }
196
197    fn extract_packages(path: &Path) -> Vec<PackageData> {
198        let toml_content = match read_julia_toml(path) {
199            Ok(content) => content,
200            Err(e) => {
201                warn!("Failed to read or parse Manifest.toml at {:?}: {}", path, e);
202                return vec![];
203            }
204        };
205
206        extract_manifest_packages(&toml_content)
207    }
208
209    fn is_match(path: &Path) -> bool {
210        path.file_name()
211            .and_then(|name| name.to_str())
212            .is_some_and(|name| name.eq_ignore_ascii_case("Manifest.toml"))
213    }
214}
215
216fn read_julia_toml(path: &Path) -> Result<Value, String> {
217    let content =
218        read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))?;
219    toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))
220}
221
222fn create_package_url(
223    name: &Option<String>,
224    version: &Option<String>,
225    uuid: Option<&str>,
226) -> Option<String> {
227    let name = name.as_ref()?;
228    // The julia purl-spec marks `uuid` as a required qualifier (the registry
229    // identity). Without it the PURL is spec-invalid, so omit it entirely
230    // rather than emit one missing the uuid.
231    let uuid = uuid?;
232
233    let mut package_url = match PackageUrl::new(PackageType::Julia.as_str(), name) {
234        Ok(p) => p,
235        Err(e) => {
236            warn!(
237                "Failed to create PackageUrl for julia package '{}': {}",
238                name, e
239            );
240            return None;
241        }
242    };
243
244    if let Some(v) = version
245        && let Err(e) = package_url.with_version(v)
246    {
247        warn!(
248            "Failed to set version '{}' for julia package '{}': {}",
249            v, name, e
250        );
251        return None;
252    }
253
254    if let Err(e) = package_url.add_qualifier("uuid", uuid) {
255        warn!(
256            "Failed to set uuid '{}' for julia package '{}': {}",
257            uuid, name, e
258        );
259        return None;
260    }
261
262    Some(truncate_field(package_url.to_string()))
263}
264
265fn extract_parties(toml_content: &Value) -> Vec<Party> {
266    use std::collections::HashSet;
267
268    let mut parties = Vec::new();
269    let mut seen = HashSet::new();
270
271    if let Some(authors) = toml_content.get(FIELD_AUTHORS).and_then(|v| v.as_array()) {
272        let limit = capped_iteration_limit(authors.len(), "Julia authors field");
273        for author in authors.iter().take(limit) {
274            push_author_party(author, &mut parties, &mut seen);
275        }
276    }
277
278    if let Some(author_value) = toml_content.get(FIELD_AUTHOR) {
279        match author_value {
280            Value::Array(authors) => {
281                let limit = capped_iteration_limit(authors.len(), "Julia author field");
282                for author in authors.iter().take(limit) {
283                    push_author_party(author, &mut parties, &mut seen);
284                }
285            }
286            other => push_author_party(other, &mut parties, &mut seen),
287        }
288    }
289
290    parties
291}
292
293fn push_author_party(
294    value: &Value,
295    parties: &mut Vec<Party>,
296    seen: &mut std::collections::HashSet<String>,
297) {
298    let Some(author_str) = value.as_str() else {
299        return;
300    };
301
302    let author_name = truncate_field(author_str.trim().to_string());
303    if author_name.is_empty() || !seen.insert(author_name.clone()) {
304        return;
305    }
306
307    parties.push(Party {
308        r#type: None,
309        role: Some("author".to_string()),
310        name: Some(author_name),
311        email: None,
312        url: None,
313        organization: None,
314        organization_url: None,
315        timezone: None,
316    });
317}
318
319fn extract_project_dependencies(toml_content: &Value) -> Vec<Dependency> {
320    let mut dependencies = Vec::new();
321
322    let deps_table = match toml_content.get(FIELD_DEPS).and_then(|v| v.as_table()) {
323        Some(table) => table,
324        None => return dependencies,
325    };
326
327    let compat_table = toml_content.get(FIELD_COMPAT).and_then(|v| v.as_table());
328
329    let limit = capped_iteration_limit(deps_table.len(), "Project.toml deps");
330    for (dep_name, dep_value) in deps_table.iter().take(limit) {
331        let uuid = dep_value.as_str().map(String::from);
332
333        let extracted_requirement = compat_table
334            .and_then(|ct| ct.get(dep_name))
335            .and_then(|v| v.as_str())
336            .map(|s| truncate_field(s.to_string()));
337
338        let is_pinned = extracted_requirement
339            .as_deref()
340            .is_some_and(is_julia_version_pinned);
341
342        let Some(purl) = create_package_url(&Some(dep_name.clone()), &None, uuid.as_deref()) else {
343            continue;
344        };
345
346        let mut extra_data_map = std::collections::HashMap::new();
347        if let Some(ref uuid_val) = uuid {
348            extra_data_map.insert("uuid".to_string(), serde_json::json!(uuid_val));
349        }
350
351        dependencies.push(Dependency {
352            purl: Some(purl),
353            extracted_requirement,
354            scope: Some("dependencies".to_string()),
355            is_runtime: Some(true),
356            is_optional: None,
357            is_pinned: Some(is_pinned),
358            is_direct: Some(true),
359            resolved_package: None,
360            extra_data: if extra_data_map.is_empty() {
361                None
362            } else {
363                Some(extra_data_map)
364            },
365        });
366    }
367
368    dependencies
369}
370
371fn extract_manifest_packages(toml_content: &Value) -> Vec<PackageData> {
372    let mut packages = Vec::new();
373
374    // Manifest.toml format 2.0 (Julia 1.7+) nests packages under a [deps] table; format
375    // 1.0 (Julia 1.0-1.6) lists them as root-level [[PackageName]] array-of-tables. Prefer
376    // the [deps] table when present, otherwise fall back to the root table. Non-package
377    // metadata keys (manifest_format, julia_version, project_hash, ...) are scalars, so the
378    // as_array() check below skips them.
379    let deps_table = match toml_content.get(FIELD_DEPS).and_then(|v| v.as_table()) {
380        Some(table) => table,
381        None => match toml_content.as_table() {
382            Some(table) => table,
383            None => return packages,
384        },
385    };
386
387    let table_limit = capped_iteration_limit(deps_table.len(), "Manifest.toml deps");
388    for (dep_name, dep_value) in deps_table.iter().take(table_limit) {
389        let dep_entries = match dep_value.as_array() {
390            Some(entries) => entries,
391            None => continue,
392        };
393
394        let entry_limit = capped_iteration_limit(dep_entries.len(), "Manifest.toml dep entries");
395        for dep_entry in dep_entries.iter().take(entry_limit) {
396            let name = Some(truncate_field(dep_name.clone()));
397
398            let uuid = dep_entry
399                .get(FIELD_UUID)
400                .and_then(|v| v.as_str())
401                .map(String::from);
402
403            let version = dep_entry
404                .get(FIELD_VERSION)
405                .and_then(|v| v.as_str())
406                .map(|s| truncate_field(s.to_string()));
407
408            let purl = create_package_url(&name, &version, uuid.as_deref());
409
410            let tree_hash = dep_entry
411                .get("git-tree-sha1")
412                .and_then(|v| v.as_str())
413                .map(String::from);
414
415            let source_url = dep_entry
416                .get("url")
417                .and_then(|v| v.as_str())
418                .map(|s| truncate_field(s.to_string()));
419
420            let mut extra_data_map = std::collections::HashMap::new();
421            if let Some(ref uuid_val) = uuid {
422                extra_data_map.insert("uuid".to_string(), serde_json::json!(uuid_val));
423            }
424            if let Some(ref tree_hash_val) = tree_hash {
425                extra_data_map.insert("tree_hash".to_string(), serde_json::json!(tree_hash_val));
426            }
427            if let Some(ref source_url_val) = source_url {
428                extra_data_map.insert("url".to_string(), serde_json::json!(source_url_val));
429            }
430
431            packages.push(PackageData {
432                package_type: Some(PackageType::Julia),
433                namespace: None,
434                name,
435                version,
436                qualifiers: None,
437                subpath: None,
438                primary_language: Some("Julia".to_string()),
439                description: None,
440                release_date: None,
441                parties: Vec::new(),
442                keywords: Vec::new(),
443                homepage_url: None,
444                download_url: None,
445                size: None,
446                sha1: None,
447                md5: None,
448                sha256: None,
449                sha512: None,
450                bug_tracking_url: None,
451                code_view_url: None,
452                vcs_url: source_url,
453                copyright: None,
454                holder: None,
455                declared_license_expression: None,
456                declared_license_expression_spdx: None,
457                license_detections: Vec::new(),
458                other_license_expression: None,
459                other_license_expression_spdx: None,
460                other_license_detections: Vec::new(),
461                extracted_license_statement: None,
462                notice_text: None,
463                source_packages: Vec::new(),
464                file_references: Vec::new(),
465                is_private: false,
466                is_virtual: false,
467                extra_data: if extra_data_map.is_empty() {
468                    None
469                } else {
470                    Some(extra_data_map)
471                },
472                dependencies: Vec::new(),
473                repository_homepage_url: None,
474                repository_download_url: None,
475                api_data_url: None,
476                datasource_id: Some(DatasourceId::JuliaManifestToml),
477                purl,
478            });
479        }
480    }
481
482    packages
483}
484
485fn extract_project_extra_data(
486    toml_content: &Value,
487) -> Option<std::collections::HashMap<String, serde_json::Value>> {
488    use serde_json::json;
489    let mut extra_data = std::collections::HashMap::new();
490
491    if let Some(uuid) = toml_content.get(FIELD_UUID).and_then(|v| v.as_str()) {
492        extra_data.insert("uuid".to_string(), json!(uuid));
493    }
494
495    if let Some(targets) = toml_content.get(FIELD_TARGETS) {
496        extra_data.insert("targets".to_string(), toml_to_json(targets));
497    }
498
499    if let Some(compat) = toml_content.get(FIELD_COMPAT) {
500        extra_data.insert("compat".to_string(), toml_to_json(compat));
501    }
502
503    if let Some(deps) = toml_content.get(FIELD_DEPS) {
504        extra_data.insert("deps".to_string(), toml_to_json(deps));
505    }
506
507    if let Some(extras) = toml_content.get("extras") {
508        extra_data.insert("extras".to_string(), toml_to_json(extras));
509    }
510
511    if let Some(sources) = toml_content.get("sources") {
512        extra_data.insert("sources".to_string(), toml_to_json(sources));
513    }
514
515    if extra_data.is_empty() {
516        None
517    } else {
518        Some(extra_data)
519    }
520}
521
522fn toml_to_json(value: &toml::Value) -> serde_json::Value {
523    toml_to_json_inner(value, &mut RecursionGuard::depth_only())
524}
525
526fn toml_to_json_inner(value: &toml::Value, guard: &mut RecursionGuard<()>) -> serde_json::Value {
527    if guard.descend() {
528        warn!("Recursion depth exceeded in toml_to_json, returning Null");
529        return serde_json::Value::Null;
530    }
531
532    let result = match value {
533        toml::Value::String(s) => serde_json::json!(s),
534        toml::Value::Integer(i) => serde_json::json!(i),
535        toml::Value::Float(f) => serde_json::json!(f),
536        toml::Value::Boolean(b) => serde_json::json!(b),
537        toml::Value::Array(a) => {
538            serde_json::Value::Array(a.iter().map(|v| toml_to_json_inner(v, guard)).collect())
539        }
540        toml::Value::Table(t) => {
541            let map: serde_json::Map<String, serde_json::Value> = t
542                .iter()
543                .map(|(k, v)| (k.clone(), toml_to_json_inner(v, guard)))
544                .collect();
545            serde_json::Value::Object(map)
546        }
547        toml::Value::Datetime(d) => serde_json::json!(d.to_string()),
548    };
549    guard.ascend();
550    result
551}
552
553fn default_project_package_data() -> PackageData {
554    PackageData {
555        package_type: Some(PackageType::Julia),
556        datasource_id: Some(DatasourceId::JuliaProjectToml),
557        ..Default::default()
558    }
559}
560
561fn is_julia_version_pinned(version_str: &str) -> bool {
562    let trimmed = version_str.trim();
563    if trimmed.is_empty() {
564        return false;
565    }
566    if trimmed.contains('^')
567        || trimmed.contains('~')
568        || trimmed.contains('>')
569        || trimmed.contains('<')
570        || trimmed.contains('*')
571    {
572        return false;
573    }
574    trimmed.matches('.').count() >= 2
575}