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