Skip to main content

provenant/parsers/
clojure.rs

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