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