Skip to main content

provenant/parsers/
deno.rs

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