1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::parser_warn as warn;
5use crate::parsers::utils::{MAX_ITERATION_COUNT, truncate_field};
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 crate::parsers::utils::read_file_to_string(path, None) {
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| truncate_field(value.to_string())),
65 Some(truncate_field(name.to_string())),
66 )
67 })
68 .unwrap_or((None, None));
69 let version = extract_non_empty_string(json, FIELD_VERSION).map(truncate_field);
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: purl.map(truncate_field),
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 .take(MAX_ITERATION_COUNT)
129 .filter_map(|(alias, value)| {
130 value
131 .as_str()
132 .map(|specifier| build_import_dependency(alias, specifier))
133 })
134 .collect()
135}
136
137fn build_import_dependency(alias: &str, specifier: &str) -> Dependency {
138 let (purl, is_pinned) = if let Some((namespace, name, version)) = parse_jsr_specifier(specifier)
139 {
140 (
141 create_generic_purl(Some(&format!("jsr.io/{}", namespace)), &name, None),
142 Some(version.is_some_and(is_exact_version)),
143 )
144 } else if let Some((namespace, name, version)) = parse_npm_specifier(specifier) {
145 (
146 create_npm_purl(namespace.as_deref(), &name, None),
147 Some(version.is_some_and(is_exact_version)),
148 )
149 } else {
150 (create_remote_purl(specifier), Some(false))
151 };
152
153 Dependency {
154 purl: purl.map(truncate_field),
155 extracted_requirement: Some(truncate_field(specifier.to_string())),
156 scope: Some("imports".to_string()),
157 is_runtime: Some(true),
158 is_optional: Some(false),
159 is_pinned,
160 is_direct: Some(true),
161 resolved_package: None,
162 extra_data: Some(HashMap::from([(
163 truncate_field("import_alias".to_string()),
164 Value::String(truncate_field(alias.to_string())),
165 )])),
166 }
167}
168
169fn parse_jsr_specifier(specifier: &str) -> Option<(String, String, Option<&str>)> {
170 let rest = specifier.strip_prefix("jsr:")?;
171 let slash_index = rest.find('/')?;
172 let namespace = rest[..slash_index].to_string();
173 let name_and_version = &rest[slash_index + 1..];
174 let (name, version) = split_name_and_version(name_and_version);
175 Some((namespace, name.to_string(), version))
176}
177
178fn parse_npm_specifier(specifier: &str) -> Option<(Option<String>, String, Option<&str>)> {
179 let rest = specifier.strip_prefix("npm:")?;
180 let (name_part, version) = split_name_and_version(rest);
181 if let Some(scoped) = name_part.strip_prefix('@') {
182 let slash_index = scoped.find('/')?;
183 let namespace = format!("@{}", &scoped[..slash_index]);
184 let name = scoped[slash_index + 1..].to_string();
185 Some((Some(namespace), name, version))
186 } else {
187 Some((None, name_part.to_string(), version))
188 }
189}
190
191fn split_name_and_version(input: &str) -> (&str, Option<&str>) {
192 if let Some(index) = input.rfind('@') {
193 let (name, version) = input.split_at(index);
194 if !name.is_empty() {
195 return (name, Some(&version[1..]));
196 }
197 }
198 (input, None)
199}
200
201fn extract_extra_data(json: &Value) -> Option<HashMap<String, Value>> {
202 let mut extra_data = HashMap::new();
203 for field in [
204 FIELD_EXPORTS,
205 FIELD_IMPORTS,
206 FIELD_SCOPES,
207 FIELD_LINKS,
208 FIELD_TASKS,
209 FIELD_LOCK,
210 FIELD_NODE_MODULES_DIR,
211 FIELD_WORKSPACE,
212 ] {
213 if let Some(value) = json.get(field) {
214 extra_data.insert(field.to_string(), value.clone());
215 }
216 }
217 (!extra_data.is_empty()).then_some(extra_data)
218}
219
220fn extract_non_empty_string(json: &Value, field: &str) -> Option<String> {
221 json.get(field)
222 .and_then(Value::as_str)
223 .map(str::trim)
224 .filter(|value| !value.is_empty())
225 .map(|value| value.to_string())
226}
227
228fn create_npm_purl(namespace: Option<&str>, name: &str, version: Option<&str>) -> Option<String> {
229 let mut purl = PackageUrl::new("npm", name).ok()?;
230 if let Some(namespace) = namespace {
231 purl.with_namespace(namespace).ok()?;
232 }
233 if let Some(version) = version
234 && is_exact_version(version)
235 {
236 purl.with_version(version).ok()?;
237 }
238 Some(purl.to_string())
239}
240
241fn create_generic_purl(
242 namespace: Option<&str>,
243 name: &str,
244 version: Option<&str>,
245) -> Option<String> {
246 let mut purl = PackageUrl::new("generic", name).ok()?;
247 if let Some(namespace) = namespace {
248 purl.with_namespace(namespace).ok()?;
249 }
250 if let Some(version) = version
251 && !version.is_empty()
252 {
253 purl.with_version(version).ok()?;
254 }
255 Some(purl.to_string())
256}
257
258fn create_remote_purl(specifier: &str) -> Option<String> {
259 let url = Url::parse(specifier).ok()?;
260 let segments: Vec<&str> = url.path_segments()?.collect();
261 let name = segments.last()?.to_string();
262 let namespace = if segments.len() > 1 {
263 Some(format!(
264 "{}/{}",
265 url.host_str()?,
266 segments[..segments.len() - 1].join("/")
267 ))
268 } else {
269 url.host_str().map(|host| host.to_string())
270 };
271 create_generic_purl(namespace.as_deref(), &name, None)
272}
273
274fn split_package_identity(name: &str) -> (Option<&str>, &str) {
275 if let Some(scoped) = name.strip_prefix('@')
276 && let Some(slash_index) = scoped.find('/')
277 {
278 return (Some(&name[..slash_index + 1]), &scoped[slash_index + 1..]);
279 }
280 (None, name)
281}
282
283fn is_exact_version(version: &str) -> bool {
284 !version.contains('^')
285 && !version.contains('~')
286 && !version.contains('*')
287 && !version.contains('>')
288 && !version.contains('<')
289 && !version.contains('|')
290 && !version.contains(' ')
291}
292
293fn default_package_data() -> PackageData {
294 PackageData {
295 package_type: Some(DenoParser::PACKAGE_TYPE),
296 primary_language: Some("TypeScript".to_string()),
297 datasource_id: Some(DatasourceId::DenoJson),
298 ..Default::default()
299 }
300}
301
302crate::register_parser!(
303 "Deno configuration",
304 &["**/deno.json", "**/deno.jsonc"],
305 "deno",
306 "TypeScript",
307 Some("https://docs.deno.com/runtime/fundamentals/configuration/"),
308);