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