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