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