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