Skip to main content

provenant/parsers/
clojure.rs

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