Skip to main content

provenant/parsers/
deno.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashMap;
5use std::path::Path;
6
7use crate::parser_warn as warn;
8use crate::parsers::utils::{MAX_ITERATION_COUNT, truncate_field};
9use packageurl::PackageUrl;
10use serde_json::Value;
11use url::Url;
12
13use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
14
15use super::PackageParser;
16use super::metadata::ParserMetadata;
17
18const FIELD_NAME: &str = "name";
19const FIELD_VERSION: &str = "version";
20const FIELD_EXPORTS: &str = "exports";
21const FIELD_IMPORTS: &str = "imports";
22const FIELD_SCOPES: &str = "scopes";
23const FIELD_LINKS: &str = "links";
24const FIELD_TASKS: &str = "tasks";
25const FIELD_LOCK: &str = "lock";
26const FIELD_NODE_MODULES_DIR: &str = "nodeModulesDir";
27const FIELD_WORKSPACE: &str = "workspace";
28
29pub struct DenoParser;
30
31impl PackageParser for DenoParser {
32    const PACKAGE_TYPE: PackageType = PackageType::Deno;
33
34    fn metadata() -> Vec<ParserMetadata> {
35        vec![ParserMetadata {
36            description: "Deno configuration",
37            file_patterns: &["**/deno.json", "**/deno.jsonc"],
38            package_type: "deno",
39            primary_language: "TypeScript",
40            documentation_url: Some("https://docs.deno.com/runtime/fundamentals/configuration/"),
41        }]
42    }
43
44    fn is_match(path: &Path) -> bool {
45        path.file_name()
46            .and_then(|name| name.to_str())
47            .is_some_and(|name| name == "deno.json" || name == "deno.jsonc")
48    }
49
50    fn extract_packages(path: &Path) -> Vec<PackageData> {
51        let content = match crate::parsers::utils::read_file_to_string(path, None) {
52            Ok(content) => content,
53            Err(e) => {
54                warn!("Failed to read Deno config at {:?}: {}", path, e);
55                return vec![default_package_data()];
56            }
57        };
58
59        let json: Value = match json5::from_str(&content) {
60            Ok(json) => json,
61            Err(e) => {
62                warn!("Failed to parse Deno config at {:?}: {}", path, e);
63                return vec![default_package_data()];
64            }
65        };
66
67        vec![parse_deno_config(&json)]
68    }
69}
70
71fn parse_deno_config(json: &Value) -> PackageData {
72    let raw_name = extract_non_empty_string(json, FIELD_NAME);
73    let (namespace, name) = raw_name
74        .as_deref()
75        .map(split_package_identity)
76        .map(|(namespace, name)| {
77            (
78                namespace.map(|value| truncate_field(value.to_string())),
79                Some(truncate_field(name.to_string())),
80            )
81        })
82        .unwrap_or((None, None));
83    let version = extract_non_empty_string(json, FIELD_VERSION).map(truncate_field);
84    let dependencies = extract_import_dependencies(json);
85    let extra_data = extract_extra_data(json);
86    let purl = match (namespace.as_deref(), name.as_deref(), version.as_deref()) {
87        (_, Some(name), version) => create_generic_purl(namespace.as_deref(), name, version),
88        _ => None,
89    };
90
91    PackageData {
92        package_type: Some(DenoParser::PACKAGE_TYPE),
93        namespace,
94        name,
95        version,
96        qualifiers: None,
97        subpath: None,
98        primary_language: Some("TypeScript".to_string()),
99        description: None,
100        release_date: None,
101        parties: Vec::new(),
102        keywords: Vec::new(),
103        homepage_url: None,
104        download_url: None,
105        size: None,
106        sha1: None,
107        md5: None,
108        sha256: None,
109        sha512: None,
110        bug_tracking_url: None,
111        code_view_url: None,
112        vcs_url: None,
113        copyright: None,
114        holder: None,
115        declared_license_expression: None,
116        declared_license_expression_spdx: None,
117        license_detections: Vec::new(),
118        other_license_expression: None,
119        other_license_expression_spdx: None,
120        other_license_detections: Vec::new(),
121        extracted_license_statement: None,
122        notice_text: None,
123        source_packages: Vec::new(),
124        file_references: Vec::new(),
125        is_private: false,
126        is_virtual: false,
127        extra_data,
128        dependencies,
129        repository_homepage_url: None,
130        repository_download_url: None,
131        api_data_url: None,
132        datasource_id: Some(DatasourceId::DenoJson),
133        purl: purl.map(truncate_field),
134    }
135}
136
137fn extract_import_dependencies(json: &Value) -> Vec<Dependency> {
138    json.get(FIELD_IMPORTS)
139        .and_then(Value::as_object)
140        .into_iter()
141        .flatten()
142        .take(MAX_ITERATION_COUNT)
143        .filter_map(|(alias, value)| {
144            value
145                .as_str()
146                .map(|specifier| build_import_dependency(alias, specifier))
147        })
148        .collect()
149}
150
151fn build_import_dependency(alias: &str, specifier: &str) -> Dependency {
152    let (purl, is_pinned) = if let Some((namespace, name, version)) = parse_jsr_specifier(specifier)
153    {
154        (
155            create_generic_purl(Some(&format!("jsr.io/{}", namespace)), &name, None),
156            Some(version.is_some_and(is_exact_version)),
157        )
158    } else if let Some((namespace, name, version)) = parse_npm_specifier(specifier) {
159        (
160            create_npm_purl(namespace.as_deref(), &name, None),
161            Some(version.is_some_and(is_exact_version)),
162        )
163    } else {
164        (create_remote_purl(specifier), Some(false))
165    };
166
167    Dependency {
168        purl: purl.map(truncate_field),
169        extracted_requirement: Some(truncate_field(specifier.to_string())),
170        scope: Some("imports".to_string()),
171        is_runtime: Some(true),
172        is_optional: Some(false),
173        is_pinned,
174        is_direct: Some(true),
175        resolved_package: None,
176        extra_data: Some(HashMap::from([(
177            truncate_field("import_alias".to_string()),
178            Value::String(truncate_field(alias.to_string())),
179        )])),
180    }
181}
182
183fn parse_jsr_specifier(specifier: &str) -> Option<(String, String, Option<&str>)> {
184    let rest = specifier.strip_prefix("jsr:")?;
185    let slash_index = rest.find('/')?;
186    let namespace = rest[..slash_index].to_string();
187    let name_and_version = &rest[slash_index + 1..];
188    let (name, version) = split_name_and_version(name_and_version);
189    Some((namespace, name.to_string(), version))
190}
191
192fn parse_npm_specifier(specifier: &str) -> Option<(Option<String>, String, Option<&str>)> {
193    let rest = specifier.strip_prefix("npm:")?;
194    let (name_part, version) = split_name_and_version(rest);
195    if let Some(scoped) = name_part.strip_prefix('@') {
196        let slash_index = scoped.find('/')?;
197        let namespace = format!("@{}", &scoped[..slash_index]);
198        let name = scoped[slash_index + 1..].to_string();
199        Some((Some(namespace), name, version))
200    } else {
201        Some((None, name_part.to_string(), version))
202    }
203}
204
205fn split_name_and_version(input: &str) -> (&str, Option<&str>) {
206    if let Some(index) = input.rfind('@') {
207        let (name, version) = input.split_at(index);
208        if !name.is_empty() {
209            return (name, Some(&version[1..]));
210        }
211    }
212    (input, None)
213}
214
215fn extract_extra_data(json: &Value) -> Option<HashMap<String, Value>> {
216    let mut extra_data = HashMap::new();
217    for field in [
218        FIELD_EXPORTS,
219        FIELD_IMPORTS,
220        FIELD_SCOPES,
221        FIELD_LINKS,
222        FIELD_TASKS,
223        FIELD_LOCK,
224        FIELD_NODE_MODULES_DIR,
225        FIELD_WORKSPACE,
226    ] {
227        if let Some(value) = json.get(field) {
228            extra_data.insert(field.to_string(), value.clone());
229        }
230    }
231    (!extra_data.is_empty()).then_some(extra_data)
232}
233
234fn extract_non_empty_string(json: &Value, field: &str) -> Option<String> {
235    json.get(field)
236        .and_then(Value::as_str)
237        .map(str::trim)
238        .filter(|value| !value.is_empty())
239        .map(|value| value.to_string())
240}
241
242fn create_npm_purl(namespace: Option<&str>, name: &str, version: Option<&str>) -> Option<String> {
243    let mut purl = PackageUrl::new("npm", name).ok()?;
244    if let Some(namespace) = namespace {
245        purl.with_namespace(namespace).ok()?;
246    }
247    if let Some(version) = version
248        && is_exact_version(version)
249    {
250        purl.with_version(version).ok()?;
251    }
252    Some(purl.to_string())
253}
254
255fn create_generic_purl(
256    namespace: Option<&str>,
257    name: &str,
258    version: Option<&str>,
259) -> Option<String> {
260    let mut purl = PackageUrl::new("generic", name).ok()?;
261    if let Some(namespace) = namespace {
262        purl.with_namespace(namespace).ok()?;
263    }
264    if let Some(version) = version
265        && !version.is_empty()
266    {
267        purl.with_version(version).ok()?;
268    }
269    Some(purl.to_string())
270}
271
272fn create_remote_purl(specifier: &str) -> Option<String> {
273    let url = Url::parse(specifier).ok()?;
274    let segments: Vec<&str> = url.path_segments()?.collect();
275    let name = segments.last()?.to_string();
276    let namespace = if segments.len() > 1 {
277        Some(format!(
278            "{}/{}",
279            url.host_str()?,
280            segments[..segments.len() - 1].join("/")
281        ))
282    } else {
283        url.host_str().map(|host| host.to_string())
284    };
285    create_generic_purl(namespace.as_deref(), &name, None)
286}
287
288fn split_package_identity(name: &str) -> (Option<&str>, &str) {
289    if let Some(scoped) = name.strip_prefix('@')
290        && let Some(slash_index) = scoped.find('/')
291    {
292        return (Some(&name[..slash_index + 1]), &scoped[slash_index + 1..]);
293    }
294    (None, name)
295}
296
297fn is_exact_version(version: &str) -> bool {
298    !version.contains('^')
299        && !version.contains('~')
300        && !version.contains('*')
301        && !version.contains('>')
302        && !version.contains('<')
303        && !version.contains('|')
304        && !version.contains(' ')
305}
306
307fn default_package_data() -> PackageData {
308    PackageData {
309        package_type: Some(DenoParser::PACKAGE_TYPE),
310        primary_language: Some("TypeScript".to_string()),
311        datasource_id: Some(DatasourceId::DenoJson),
312        ..Default::default()
313    }
314}