1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::parser_warn as warn;
5use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
6use packageurl::PackageUrl;
7use serde_json::Value as JsonValue;
8
9use crate::models::{DatasourceId, Dependency, PackageData, PackageType};
10
11use super::PackageParser;
12
13const MAX_RECURSION_DEPTH: usize = 50;
14
15pub struct ClojureDepsEdnParser;
16
17impl PackageParser for ClojureDepsEdnParser {
18 const PACKAGE_TYPE: PackageType = PackageType::Maven;
19
20 fn is_match(path: &Path) -> bool {
21 path.file_name().is_some_and(|name| name == "deps.edn")
22 }
23
24 fn extract_packages(path: &Path) -> Vec<PackageData> {
25 let content = match read_file_to_string(path, None) {
26 Ok(content) => content,
27 Err(error) => {
28 warn!("Failed to read deps.edn at {:?}: {}", path, error);
29 return vec![default_package_data(Some(DatasourceId::ClojureDepsEdn))];
30 }
31 };
32
33 match parse_forms(&content)
34 .and_then(|forms| {
35 forms
36 .into_iter()
37 .next()
38 .ok_or_else(|| "deps.edn contained no readable forms".to_string())
39 })
40 .and_then(|form| parse_deps_edn_form(&form))
41 {
42 Ok(package) => vec![package],
43 Err(error) => {
44 warn!("Failed to parse deps.edn at {:?}: {}", path, error);
45 vec![default_package_data(Some(DatasourceId::ClojureDepsEdn))]
46 }
47 }
48 }
49}
50
51pub struct ClojureProjectCljParser;
52
53impl PackageParser for ClojureProjectCljParser {
54 const PACKAGE_TYPE: PackageType = PackageType::Maven;
55
56 fn is_match(path: &Path) -> bool {
57 path.file_name().is_some_and(|name| name == "project.clj")
58 }
59
60 fn extract_packages(path: &Path) -> Vec<PackageData> {
61 let content = match read_file_to_string(path, None) {
62 Ok(content) => content,
63 Err(error) => {
64 warn!("Failed to read project.clj at {:?}: {}", path, error);
65 return vec![default_package_data(Some(DatasourceId::ClojureProjectClj))];
66 }
67 };
68
69 if looks_like_template_project_clj(&content) {
70 return vec![default_package_data(Some(DatasourceId::ClojureProjectClj))];
71 }
72
73 if !content.contains("(defproject") {
74 return vec![default_package_data(Some(DatasourceId::ClojureProjectClj))];
75 }
76
77 let forms = match parse_forms(&content) {
78 Ok(forms) => forms,
79 Err(error) => {
80 warn!("Failed to parse project.clj at {:?}: {}", path, error);
81 return vec![default_package_data(Some(DatasourceId::ClojureProjectClj))];
82 }
83 };
84
85 let Some(form) = forms.into_iter().find(|form| {
86 matches!(
87 form,
88 Form::List(items) if matches!(items.first(), Some(Form::Symbol(symbol)) if symbol == "defproject")
89 )
90 }) else {
91 return vec![default_package_data(Some(DatasourceId::ClojureProjectClj))];
92 };
93
94 match parse_project_clj_form(&form) {
95 Ok(package) => vec![package],
96 Err(error) => {
97 warn!("Failed to parse project.clj at {:?}: {}", path, error);
98 vec![default_package_data(Some(DatasourceId::ClojureProjectClj))]
99 }
100 }
101 }
102}
103
104#[derive(Clone, Debug)]
105enum Form {
106 Nil,
107 Bool(bool),
108 String(String),
109 Keyword(String),
110 Symbol(String),
111 Vector(Vec<Form>),
112 List(Vec<Form>),
113 Map(Vec<(Form, Form)>),
114 Prefixed(Box<Form>),
115}
116
117struct Reader {
118 chars: Vec<char>,
119 index: usize,
120 depth: usize,
121}
122
123impl Reader {
124 fn new(input: &str) -> Self {
125 Self {
126 chars: input.chars().collect(),
127 index: 0,
128 depth: 0,
129 }
130 }
131
132 fn parse_all(mut self) -> Result<Vec<Form>, String> {
133 let mut forms = Vec::new();
134 let mut count = 0usize;
135 while self.skip_ws_and_comments() {
136 count += 1;
137 if count > MAX_ITERATION_COUNT {
138 warn!("Reached MAX_ITERATION_COUNT in parse_all, stopping early");
139 break;
140 }
141 forms.push(self.parse_form()?);
142 }
143 Ok(forms)
144 }
145
146 fn skip_ws_and_comments(&mut self) -> bool {
147 loop {
148 while self
149 .peek()
150 .is_some_and(|ch| ch.is_whitespace() || ch == ',')
151 {
152 self.index += 1;
153 }
154 if self.peek() == Some(';') {
155 while let Some(ch) = self.peek() {
156 self.index += 1;
157 if ch == '\n' {
158 break;
159 }
160 }
161 continue;
162 }
163 return self.peek().is_some();
164 }
165 }
166
167 fn parse_form(&mut self) -> Result<Form, String> {
168 if self.depth > MAX_RECURSION_DEPTH {
169 return Err("recursion depth exceeded".to_string());
170 }
171 self.skip_ws_and_comments();
172 match self.peek() {
173 Some('"') => self.parse_string().map(Form::String),
174 Some(':') => self.parse_keyword().map(Form::Keyword),
175 Some('[') => {
176 self.depth += 1;
177 let result = self.parse_collection('[', ']').map(Form::Vector);
178 self.depth -= 1;
179 result
180 }
181 Some('(') => {
182 self.depth += 1;
183 let result = self.parse_collection('(', ')').map(Form::List);
184 self.depth -= 1;
185 result
186 }
187 Some('{') => {
188 self.depth += 1;
189 let result = self.parse_map();
190 self.depth -= 1;
191 result
192 }
193 Some('^') => {
194 self.index += 1;
195 self.depth += 1;
196 let _ = self.parse_form()?;
197 let result = self.parse_form();
198 self.depth -= 1;
199 result
200 }
201 Some('~') | Some('\'') | Some('`') | Some('@') => {
202 self.index += 1;
203 self.depth += 1;
204 let form = self.parse_form()?;
205 self.depth -= 1;
206 Ok(Form::Prefixed(Box::new(form)))
207 }
208 Some('#') => {
209 self.depth += 1;
210 let result = self.parse_dispatch_form();
211 self.depth -= 1;
212 result
213 }
214 Some(_) => self.parse_atom(),
215 None => Err("unexpected end of input".to_string()),
216 }
217 }
218
219 fn parse_dispatch_form(&mut self) -> Result<Form, String> {
220 self.expect('#')?;
221 match self.peek() {
222 Some('_') => {
223 self.index += 1;
224 let _ = self.parse_form()?;
225 self.parse_form()
226 }
227 Some('=') => Err("unsupported reader eval dispatch".to_string()),
228 Some('"') => {
229 self.parse_string().map(Form::String)
231 }
232 Some('{') => {
233 self.parse_collection('{', '}').map(Form::Vector)
235 }
236 Some('(') => {
237 self.parse_collection('(', ')').map(Form::List)
239 }
240 Some('?') => {
241 self.index += 1;
244 if self.peek() == Some('@') {
245 self.index += 1;
246 }
247 let _ = self.parse_form()?;
248 self.parse_form()
249 }
250 Some(ch) if !is_delimiter(ch) => {
251 let _ = self.parse_atom()?;
254 self.parse_form()
255 }
256 Some(ch) => Err(format!("unsupported reader dispatch '#{ch}'")),
257 None => Err("unexpected end of input after '#'".to_string()),
258 }
259 }
260
261 fn parse_string(&mut self) -> Result<String, String> {
262 self.expect('"')?;
263 let mut result = String::new();
264 let mut escaped = false;
265 while let Some(ch) = self.peek() {
266 self.index += 1;
267 if escaped {
268 result.push(match ch {
269 'n' => '\n',
270 'r' => '\r',
271 't' => '\t',
272 '"' => '"',
273 '\\' => '\\',
274 other => other,
275 });
276 escaped = false;
277 } else if ch == '\\' {
278 escaped = true;
279 } else if ch == '"' {
280 return Ok(result);
281 } else {
282 result.push(ch);
283 }
284 }
285 Err("unterminated string".to_string())
286 }
287
288 fn parse_keyword(&mut self) -> Result<String, String> {
289 self.expect(':')?;
290 let start = self.index;
291 while let Some(ch) = self.peek() {
292 if is_delimiter(ch) {
293 break;
294 }
295 self.index += 1;
296 }
297 if self.index == start {
298 return Err("empty keyword".to_string());
299 }
300 Ok(self.chars[start..self.index].iter().collect())
301 }
302
303 fn parse_collection(&mut self, open: char, close: char) -> Result<Vec<Form>, String> {
304 self.expect(open)?;
305 let mut forms = Vec::new();
306 let mut count = 0usize;
307 loop {
308 self.skip_ws_and_comments();
309 if self.peek() == Some(close) {
310 self.index += 1;
311 return Ok(forms);
312 }
313 if self.peek().is_none() {
314 return Err(format!("unterminated collection starting with {open}"));
315 }
316 count += 1;
317 if count > MAX_ITERATION_COUNT {
318 warn!("Reached MAX_ITERATION_COUNT in parse_collection, stopping early");
319 break;
320 }
321 forms.push(self.parse_form()?);
322 }
323 Ok(forms)
324 }
325
326 fn parse_map(&mut self) -> Result<Form, String> {
327 self.expect('{')?;
328 let mut entries = Vec::new();
329 let mut count = 0usize;
330 loop {
331 self.skip_ws_and_comments();
332 if self.peek() == Some('}') {
333 self.index += 1;
334 return Ok(Form::Map(entries));
335 }
336 if self.peek().is_none() {
337 return Err("unterminated map".to_string());
338 }
339 count += 1;
340 if count > MAX_ITERATION_COUNT {
341 warn!("Reached MAX_ITERATION_COUNT in parse_map, stopping early");
342 break;
343 }
344 let key = self.parse_form()?;
345 self.skip_ws_and_comments();
346 if self.peek() == Some('}') {
347 return Err("map missing value".to_string());
348 }
349 let value = self.parse_form()?;
350 entries.push((key, value));
351 }
352 Ok(Form::Map(entries))
353 }
354
355 fn parse_atom(&mut self) -> Result<Form, String> {
356 let start = self.index;
357 while let Some(ch) = self.peek() {
358 if is_delimiter(ch) {
359 break;
360 }
361 self.index += 1;
362 }
363 let token: String = self.chars[start..self.index].iter().collect();
364 if token.is_empty() {
365 return Err("empty token".to_string());
366 }
367 Ok(match token.as_str() {
368 "nil" => Form::Nil,
369 "true" => Form::Bool(true),
370 "false" => Form::Bool(false),
371 _ => Form::Symbol(token),
372 })
373 }
374
375 fn expect(&mut self, expected: char) -> Result<(), String> {
376 match self.peek() {
377 Some(ch) if ch == expected => {
378 self.index += 1;
379 Ok(())
380 }
381 Some(ch) => Err(format!("expected '{expected}', found '{ch}'")),
382 None => Err(format!("expected '{expected}', found end of input")),
383 }
384 }
385
386 fn peek(&self) -> Option<char> {
387 self.chars.get(self.index).copied()
388 }
389}
390
391fn is_delimiter(ch: char) -> bool {
392 ch.is_whitespace()
393 || ch == ','
394 || matches!(
395 ch,
396 '[' | ']' | '{' | '}' | '(' | ')' | '"' | ';' | '\'' | '`' | '~' | '@'
397 )
398}
399
400fn parse_forms(input: &str) -> Result<Vec<Form>, String> {
401 Reader::new(input).parse_all()
402}
403
404fn parse_deps_edn_form(form: &Form) -> Result<PackageData, String> {
405 let Form::Map(entries) = form else {
406 return Err("deps.edn root is not a map".to_string());
407 };
408
409 let mut package = default_package_data(Some(DatasourceId::ClojureDepsEdn));
410 let mut dependencies = Vec::new();
411 let mut extra_data = HashMap::new();
412
413 if let Some(Form::Map(dep_map)) = map_get_keyword(entries, "deps") {
414 dependencies.extend(extract_deps_map(dep_map, None, true));
415 }
416
417 if let Some(Form::Map(alias_map)) = map_get_keyword(entries, "aliases") {
418 for (alias_key, alias_value) in alias_map {
419 let Some(alias_name) = keyword_or_symbol_name(alias_key) else {
420 continue;
421 };
422 let Form::Map(alias_entries) = alias_value else {
423 continue;
424 };
425 for dep_key in [
426 "extra-deps",
427 "override-deps",
428 "default-deps",
429 "deps",
430 "replace-deps",
431 ] {
432 if let Some(Form::Map(dep_map)) = map_get_keyword(alias_entries, dep_key) {
433 dependencies.extend(extract_deps_map(dep_map, Some(&alias_name), false));
434 }
435 }
436 }
437 if let Some(json) = form_to_json(&Form::Map(alias_map.clone())) {
438 extra_data.insert("aliases".to_string(), json);
439 }
440 }
441
442 if let Some(value) = map_get_keyword(entries, "paths").and_then(form_to_json) {
443 extra_data.insert("paths".to_string(), value);
444 }
445 if let Some(value) = map_get_keyword(entries, "mvn/repos").and_then(form_to_json) {
446 extra_data.insert("mvn_repos".to_string(), value);
447 }
448
449 package.dependencies = dependencies;
450 package.extra_data = (!extra_data.is_empty()).then_some(extra_data);
451 Ok(package)
452}
453
454fn parse_project_clj_form(form: &Form) -> Result<PackageData, String> {
455 let Form::List(items) = form else {
456 return Err("project.clj root is not a list".to_string());
457 };
458 if !matches!(items.first(), Some(Form::Symbol(symbol)) if symbol == "defproject") {
459 return Err("project.clj root is not defproject".to_string());
460 }
461
462 let Some((namespace, name)) = items.get(1).and_then(parse_lib_form) else {
463 return Err("defproject missing project identifier".to_string());
464 };
465 let Some(version) = items.get(2).and_then(form_as_string) else {
466 return Err("defproject missing project version".to_string());
467 };
468
469 let mut package = default_package_data(Some(DatasourceId::ClojureProjectClj));
470 package.namespace = namespace.clone().map(truncate_field);
471 package.name = Some(truncate_field(name.clone()));
472 package.version = Some(truncate_field(version.to_string()));
473 package.purl = build_maven_purl(namespace.as_deref(), &name, Some(version)).map(truncate_field);
474
475 let mut index = 3usize;
476 while index + 1 < items.len() {
477 let Some(key) = form_as_keyword(&items[index]) else {
478 index += 1;
479 continue;
480 };
481 let value = &items[index + 1];
482
483 match key {
484 "description" => {
485 package.description = form_as_string(value).map(|s| truncate_field(s.to_owned()))
486 }
487 "url" => {
488 package.homepage_url = form_as_string(value).map(|s| truncate_field(s.to_owned()))
489 }
490 "license" => {
491 package.extracted_license_statement = format_license(value).map(truncate_field);
492 }
493 "scm" => {
494 if let Form::Map(entries) = value {
495 package.vcs_url = map_get_keyword(entries, "url")
496 .and_then(form_as_string)
497 .map(|s| truncate_field(s.to_owned()));
498 }
499 }
500 "dependencies" => {
501 if let Form::Vector(deps) = value {
502 package
503 .dependencies
504 .extend(extract_project_dependencies(deps, None));
505 }
506 }
507 "profiles" => {
508 if let Form::Map(entries) = value {
509 for (profile_key, profile_value) in entries {
510 let Some(profile_name) = keyword_or_symbol_name(profile_key) else {
511 continue;
512 };
513 let Form::Map(profile_entries) = profile_value else {
514 continue;
515 };
516 if let Some(Form::Vector(deps)) =
517 map_get_keyword(profile_entries, "dependencies")
518 {
519 package
520 .dependencies
521 .extend(extract_project_dependencies(deps, Some(&profile_name)));
522 }
523 }
524 }
525 }
526 _ => {}
527 }
528 index += 2;
529 }
530
531 Ok(package)
532}
533
534fn extract_deps_map(
535 entries: &[(Form, Form)],
536 scope: Option<&str>,
537 runtime: bool,
538) -> Vec<Dependency> {
539 entries
540 .iter()
541 .take(MAX_ITERATION_COUNT)
542 .filter_map(|(lib, coord)| build_deps_edn_dependency(lib, coord, scope, runtime))
543 .collect()
544}
545
546fn build_deps_edn_dependency(
547 lib: &Form,
548 coord: &Form,
549 scope: Option<&str>,
550 runtime: bool,
551) -> Option<Dependency> {
552 let (namespace, name) = parse_lib_form(lib)?;
553 let mut extra_data = HashMap::new();
554 let mut requirement = None;
555 let mut pinned = false;
556
557 if let Form::Map(entries) = coord {
558 if let Some(version) = map_get_keyword(entries, "mvn/version").and_then(form_as_string) {
559 requirement = Some(version.to_string());
560 pinned = is_exact_version(version);
561 }
562 for (key, data_key) in [
563 ("git/url", "git_url"),
564 ("git/tag", "git_tag"),
565 ("git/sha", "git_sha"),
566 ("deps/root", "deps_root"),
567 ("deps/manifest", "deps_manifest"),
568 ("local/root", "local_root"),
569 ("exclusions", "exclusions"),
570 ] {
571 if let Some(value) = map_get_keyword(entries, key).and_then(form_to_json) {
572 extra_data.insert(data_key.to_string(), value);
573 }
574 }
575 }
576
577 Some(Dependency {
578 purl: build_maven_purl(
579 namespace.as_deref(),
580 &name,
581 requirement.as_deref().map(strip_exact_prefix),
582 )
583 .map(truncate_field),
584 extracted_requirement: requirement.map(truncate_field),
585 scope: scope.map(ToOwned::to_owned),
586 is_runtime: Some(runtime),
587 is_optional: Some(scope.is_some()),
588 is_pinned: Some(pinned),
589 is_direct: Some(true),
590 resolved_package: None,
591 extra_data: (!extra_data.is_empty()).then_some(extra_data),
592 })
593}
594
595fn extract_project_dependencies(entries: &[Form], scope: Option<&str>) -> Vec<Dependency> {
596 entries
597 .iter()
598 .take(MAX_ITERATION_COUNT)
599 .filter_map(|entry| {
600 let Form::Vector(parts) = entry else {
601 return None;
602 };
603 let (namespace, name) = parse_lib_form(parts.first()?)?;
604 let version = form_as_string(parts.get(1)?)?;
605
606 let mut extra_data = HashMap::new();
607 let mut index = 2usize;
608 while index + 1 < parts.len() {
609 if let Some(key) = form_as_keyword(&parts[index])
610 && let Some(value) = form_to_json(&parts[index + 1])
611 {
612 extra_data.insert(key.replace('-', "_"), value);
613 }
614 index += 2;
615 }
616
617 let (is_runtime, is_optional) = match scope {
618 Some("dev") | Some("test") => (false, true),
619 Some("provided") => (false, false),
620 Some(_) => (false, true),
621 None => (true, false),
622 };
623
624 Some(Dependency {
625 purl: build_maven_purl(
626 namespace.as_deref(),
627 &name,
628 Some(strip_exact_prefix(version)),
629 )
630 .map(truncate_field),
631 extracted_requirement: Some(truncate_field(version.to_string())),
632 scope: scope.map(ToOwned::to_owned),
633 is_runtime: Some(is_runtime),
634 is_optional: Some(is_optional),
635 is_pinned: Some(is_exact_version(version)),
636 is_direct: Some(true),
637 resolved_package: None,
638 extra_data: (!extra_data.is_empty()).then_some(extra_data),
639 })
640 })
641 .collect()
642}
643
644fn parse_lib_form(form: &Form) -> Option<(Option<String>, String)> {
645 let raw = match form {
646 Form::Symbol(value) | Form::String(value) => value,
647 _ => return None,
648 };
649
650 if let Some((namespace, name)) = raw.split_once('/') {
651 Some((Some(namespace.to_string()), name.to_string()))
652 } else {
653 Some((Some(raw.to_string()), raw.to_string()))
654 }
655}
656
657fn map_get_keyword<'a>(entries: &'a [(Form, Form)], key: &str) -> Option<&'a Form> {
658 entries.iter().find_map(|(entry_key, entry_value)| {
659 if form_as_keyword(entry_key) == Some(key) {
660 Some(entry_value)
661 } else {
662 None
663 }
664 })
665}
666
667fn form_as_keyword(form: &Form) -> Option<&str> {
668 match form {
669 Form::Keyword(value) => Some(value.as_str()),
670 _ => None,
671 }
672}
673
674fn form_as_string(form: &Form) -> Option<&str> {
675 match form {
676 Form::String(value) => Some(value.as_str()),
677 _ => None,
678 }
679}
680
681fn keyword_or_symbol_name(form: &Form) -> Option<String> {
682 match form {
683 Form::Keyword(value) | Form::Symbol(value) => Some(value.clone()),
684 _ => None,
685 }
686}
687
688fn map_key_name(form: &Form) -> Option<String> {
689 match form {
690 Form::Keyword(value) | Form::Symbol(value) | Form::String(value) => Some(value.clone()),
691 _ => None,
692 }
693}
694
695fn form_to_json(form: &Form) -> Option<JsonValue> {
696 Some(match form {
697 Form::Nil => JsonValue::Null,
698 Form::Bool(value) => JsonValue::Bool(*value),
699 Form::String(value) => JsonValue::String(value.clone()),
700 Form::Keyword(value) => JsonValue::String(format!(":{value}")),
701 Form::Symbol(value) => JsonValue::String(value.clone()),
702 Form::Vector(values) | Form::List(values) => {
703 JsonValue::Array(values.iter().filter_map(form_to_json).collect())
704 }
705 Form::Map(entries) => {
706 let mut map = serde_json::Map::new();
707 for (key, value) in entries {
708 let Some(key_name) = map_key_name(key) else {
709 continue;
710 };
711 if let Some(json) = form_to_json(value) {
712 map.insert(key_name, json);
713 }
714 }
715 JsonValue::Object(map)
716 }
717 Form::Prefixed(value) => form_to_json(value)?,
718 })
719}
720
721fn format_license(form: &Form) -> Option<String> {
722 match form {
723 Form::Map(entries) => format_license_map(entries),
724 Form::Vector(values) | Form::List(values) => {
725 let licenses: Vec<String> = values.iter().filter_map(format_license).collect();
726 if licenses.is_empty() {
727 None
728 } else {
729 Some(licenses.join("\n"))
730 }
731 }
732 _ => None,
733 }
734}
735
736fn format_license_map(entries: &[(Form, Form)]) -> Option<String> {
737 let name = map_get_keyword(entries, "name").and_then(form_as_string)?;
738 let mut rendered = format!("- license:\n name: {name}\n");
739 if let Some(url) = map_get_keyword(entries, "url").and_then(form_as_string) {
740 rendered.push_str(&format!(" url: {url}\n"));
741 }
742 Some(rendered)
743}
744
745fn build_maven_purl(namespace: Option<&str>, name: &str, version: Option<&str>) -> Option<String> {
746 let mut purl = PackageUrl::new(PackageType::Maven.as_str(), name).ok()?;
747 if let Some(namespace) = namespace {
748 purl.with_namespace(namespace).ok()?;
749 }
750 if let Some(version) = version {
751 purl.with_version(version).ok()?;
752 }
753 Some(purl.to_string())
754}
755
756fn is_exact_version(version: &str) -> bool {
757 let normalized = strip_exact_prefix(version).trim();
758 !normalized.is_empty()
759 && !normalized.contains('*')
760 && !normalized.contains('^')
761 && !normalized.contains('~')
762 && !normalized.contains('>')
763 && !normalized.contains('<')
764 && !normalized.contains('|')
765 && !normalized.contains(',')
766 && !normalized.contains(' ')
767}
768
769fn strip_exact_prefix(version: &str) -> &str {
770 version.trim_start_matches('=')
771}
772
773fn looks_like_template_project_clj(content: &str) -> bool {
774 let Some(defproject_index) = content.find("(defproject") else {
775 return false;
776 };
777
778 let manifest_window = &content[defproject_index..content.len().min(defproject_index + 256)];
779 manifest_window.contains("{{") && manifest_window.contains("}}")
780}
781
782fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
783 PackageData {
784 package_type: Some(PackageType::Maven),
785 primary_language: Some("Clojure".to_string()),
786 datasource_id,
787 ..Default::default()
788 }
789}
790
791crate::register_parser!(
792 "Clojure deps.edn and project.clj manifests",
793 &["**/deps.edn", "**/project.clj"],
794 "maven",
795 "Clojure",
796 Some("https://clojure.org/reference/deps_edn"),
797);