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);