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