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