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 bindings = collect_def_bindings(&forms);
100
101        let Some(form) = forms.into_iter().find(|form| {
102            matches!(
103                form,
104                Form::List(items) if matches!(items.first(), Some(Form::Symbol(symbol)) if symbol == "defproject")
105            )
106        }) else {
107            return vec![default_package_data(Some(DatasourceId::ClojureProjectClj))];
108        };
109
110        match parse_project_clj_form(&form, &bindings) {
111            Ok(package) => vec![package],
112            Err(error) => {
113                warn!("Failed to parse project.clj at {:?}: {}", path, error);
114                vec![default_package_data(Some(DatasourceId::ClojureProjectClj))]
115            }
116        }
117    }
118}
119
120#[derive(Clone, Debug)]
121enum Form {
122    Nil,
123    Bool(bool),
124    String(String),
125    Keyword(String),
126    Symbol(String),
127    Vector(Vec<Form>),
128    List(Vec<Form>),
129    Map(Vec<(Form, Form)>),
130    /// A reader-macro-prefixed form. The `char` records which prefix was read
131    /// (`~` unquote, `'` quote, `` ` `` syntax-quote, `@` deref, or `#` for
132    /// `#'` var-quote), so consumers can distinguish an unquote — the only
133    /// prefix that means "evaluate this" — from quoting, which means "data".
134    Prefixed(char, Box<Form>),
135}
136
137struct Reader {
138    chars: Vec<char>,
139    index: usize,
140    guard: RecursionGuard<()>,
141}
142
143impl Reader {
144    fn new(input: &str) -> Self {
145        Self {
146            chars: input.chars().collect(),
147            index: 0,
148            guard: RecursionGuard::depth_only(),
149        }
150    }
151
152    fn parse_all(mut self) -> Result<Vec<Form>, String> {
153        let mut forms = Vec::new();
154        let mut count = 0usize;
155        loop {
156            self.skip_discards()?;
157            if self.peek().is_none() {
158                break;
159            }
160            count += 1;
161            if count > MAX_ITERATION_COUNT {
162                warn!("Reached MAX_ITERATION_COUNT in parse_all, stopping early");
163                break;
164            }
165            forms.push(self.parse_form()?);
166        }
167        Ok(forms)
168    }
169
170    fn skip_ws_and_comments(&mut self) -> bool {
171        loop {
172            while self
173                .peek()
174                .is_some_and(|ch| ch.is_whitespace() || ch == ',')
175            {
176                self.index += 1;
177            }
178            if self.peek() == Some(';') {
179                while let Some(ch) = self.peek() {
180                    self.index += 1;
181                    if ch == '\n' {
182                        break;
183                    }
184                }
185                continue;
186            }
187            return self.peek().is_some();
188        }
189    }
190
191    /// Skip whitespace, comments, and any leading `#_ <form>` discards, leaving
192    /// the reader at the next real token (which may be a closing delimiter or
193    /// end of input). Handling discards at form-loop boundaries — rather than
194    /// only mid-sequence — tolerates a trailing `#_` before a closing bracket,
195    /// e.g. `[:a #_"skipme"]`, which is valid Clojure that real manifests use.
196    fn skip_discards(&mut self) -> Result<(), String> {
197        loop {
198            self.skip_ws_and_comments();
199            if self.peek() == Some('#') && self.chars.get(self.index + 1) == Some(&'_') {
200                self.index += 2;
201                let _ = self.parse_form()?;
202                continue;
203            }
204            return Ok(());
205        }
206    }
207
208    fn parse_form(&mut self) -> Result<Form, String> {
209        if self.guard.descend() {
210            return Err("recursion depth exceeded".to_string());
211        }
212        self.skip_ws_and_comments();
213        let result = match self.peek() {
214            Some('"') => self.parse_string().map(Form::String),
215            Some(':') => self.parse_keyword().map(Form::Keyword),
216            Some('[') => self.parse_collection('[', ']').map(Form::Vector),
217            Some('(') => self.parse_collection('(', ')').map(Form::List),
218            Some('{') => self.parse_map(),
219            Some('^') => {
220                self.index += 1;
221                let _ = self.parse_form()?;
222                let result = self.parse_form();
223                self.guard.ascend();
224                return result;
225            }
226            Some(prefix @ ('~' | '\'' | '`' | '@')) => {
227                self.index += 1;
228                let form = self.parse_form()?;
229                self.guard.ascend();
230                return Ok(Form::Prefixed(prefix, Box::new(form)));
231            }
232            Some('#') => {
233                let result = self.parse_dispatch_form();
234                self.guard.ascend();
235                return result;
236            }
237            Some(_) => self.parse_atom(),
238            None => Err("unexpected end of input".to_string()),
239        };
240        self.guard.ascend();
241        result
242    }
243
244    fn parse_dispatch_form(&mut self) -> Result<Form, String> {
245        self.expect('#')?;
246        match self.peek() {
247            Some('_') => {
248                self.index += 1;
249                let _ = self.parse_form()?;
250                self.parse_form()
251            }
252            Some('=') => Err("unsupported reader eval dispatch".to_string()),
253            Some('\'') => {
254                // `#'` var-quote: data, not an unquote — tag with `#`.
255                self.index += 1;
256                let form = self.parse_form()?;
257                Ok(Form::Prefixed('#', Box::new(form)))
258            }
259            Some('"') => {
260                // Tolerate regex literals in ignored fields without implementing reader semantics.
261                self.parse_string().map(Form::String)
262            }
263            Some('{') => {
264                // Tolerate set literals in ignored fields by treating them as plain collections.
265                self.parse_collection('{', '}').map(Form::Vector)
266            }
267            Some('(') => {
268                // Tolerate function literals in ignored fields without implementing reader semantics.
269                self.parse_collection('(', ')').map(Form::List)
270            }
271            Some('?') => {
272                // Tolerate reader conditionals by skipping the dispatch token and
273                // returning the selected readable form without evaluating features.
274                self.index += 1;
275                if self.peek() == Some('@') {
276                    self.index += 1;
277                }
278                let _ = self.parse_form()?;
279                self.parse_form()
280            }
281            Some(ch) if !is_delimiter(ch) => {
282                // Tolerate tagged literals in ignored fields by ignoring the tag and
283                // parsing the following readable form as plain data.
284                let _ = self.parse_atom()?;
285                self.parse_form()
286            }
287            Some(ch) => Err(format!("unsupported reader dispatch '#{ch}'")),
288            None => Err("unexpected end of input after '#'".to_string()),
289        }
290    }
291
292    fn parse_string(&mut self) -> Result<String, String> {
293        self.expect('"')?;
294        let mut result = String::new();
295        let mut escaped = false;
296        while let Some(ch) = self.peek() {
297            self.index += 1;
298            if escaped {
299                result.push(match ch {
300                    'n' => '\n',
301                    'r' => '\r',
302                    't' => '\t',
303                    '"' => '"',
304                    '\\' => '\\',
305                    other => other,
306                });
307                escaped = false;
308            } else if ch == '\\' {
309                escaped = true;
310            } else if ch == '"' {
311                return Ok(result);
312            } else {
313                result.push(ch);
314            }
315        }
316        Err("unterminated string".to_string())
317    }
318
319    fn parse_keyword(&mut self) -> Result<String, String> {
320        self.expect(':')?;
321        let start = self.index;
322        while let Some(ch) = self.peek() {
323            if is_delimiter(ch) {
324                break;
325            }
326            self.index += 1;
327        }
328        if self.index == start {
329            return Err("empty keyword".to_string());
330        }
331        Ok(self.chars[start..self.index].iter().collect())
332    }
333
334    fn parse_collection(&mut self, open: char, close: char) -> Result<Vec<Form>, String> {
335        self.expect(open)?;
336        let mut forms = Vec::new();
337        let mut count = 0usize;
338        loop {
339            self.skip_discards()?;
340            if self.peek() == Some(close) {
341                self.index += 1;
342                return Ok(forms);
343            }
344            if self.peek().is_none() {
345                return Err(format!("unterminated collection starting with {open}"));
346            }
347            count += 1;
348            if count > MAX_ITERATION_COUNT {
349                warn!("Reached MAX_ITERATION_COUNT in parse_collection, stopping early");
350                break;
351            }
352            forms.push(self.parse_form()?);
353        }
354        Ok(forms)
355    }
356
357    fn parse_map(&mut self) -> Result<Form, String> {
358        self.expect('{')?;
359        let mut entries = Vec::new();
360        let mut count = 0usize;
361        loop {
362            self.skip_ws_and_comments();
363            if self.peek() == Some('}') {
364                self.index += 1;
365                return Ok(Form::Map(entries));
366            }
367            if self.peek().is_none() {
368                return Err("unterminated map".to_string());
369            }
370            count += 1;
371            if count > MAX_ITERATION_COUNT {
372                warn!("Reached MAX_ITERATION_COUNT in parse_map, stopping early");
373                break;
374            }
375            let key = self.parse_form()?;
376            self.skip_ws_and_comments();
377            if self.peek() == Some('}') {
378                return Err("map missing value".to_string());
379            }
380            let value = self.parse_form()?;
381            entries.push((key, value));
382        }
383        Ok(Form::Map(entries))
384    }
385
386    fn parse_atom(&mut self) -> Result<Form, String> {
387        let start = self.index;
388        while let Some(ch) = self.peek() {
389            if is_delimiter(ch) {
390                break;
391            }
392            self.index += 1;
393        }
394        let token: String = self.chars[start..self.index].iter().collect();
395        if token.is_empty() {
396            return Err("empty token".to_string());
397        }
398        Ok(match token.as_str() {
399            "nil" => Form::Nil,
400            "true" => Form::Bool(true),
401            "false" => Form::Bool(false),
402            _ => Form::Symbol(token),
403        })
404    }
405
406    fn expect(&mut self, expected: char) -> Result<(), String> {
407        match self.peek() {
408            Some(ch) if ch == expected => {
409                self.index += 1;
410                Ok(())
411            }
412            Some(ch) => Err(format!("expected '{expected}', found '{ch}'")),
413            None => Err(format!("expected '{expected}', found end of input")),
414        }
415    }
416
417    fn peek(&self) -> Option<char> {
418        self.chars.get(self.index).copied()
419    }
420}
421
422fn is_delimiter(ch: char) -> bool {
423    ch.is_whitespace()
424        || ch == ','
425        || matches!(
426            ch,
427            '[' | ']' | '{' | '}' | '(' | ')' | '"' | ';' | '\'' | '`' | '~' | '@'
428        )
429}
430
431fn parse_forms(input: &str) -> Result<Vec<Form>, String> {
432    Reader::new(input).parse_all()
433}
434
435fn parse_deps_edn_form(form: &Form) -> Result<PackageData, String> {
436    let Form::Map(entries) = form else {
437        return Err("deps.edn root is not a map".to_string());
438    };
439
440    let mut package = default_package_data(Some(DatasourceId::ClojureDepsEdn));
441    let mut dependencies = Vec::new();
442    let mut extra_data = HashMap::new();
443
444    if let Some(Form::Map(dep_map)) = map_get_keyword(entries, "deps") {
445        dependencies.extend(extract_deps_map(dep_map, None, true));
446    }
447
448    if let Some(Form::Map(alias_map)) = map_get_keyword(entries, "aliases") {
449        for (alias_key, alias_value) in alias_map {
450            let Some(alias_name) = keyword_or_symbol_name(alias_key) else {
451                continue;
452            };
453            let Form::Map(alias_entries) = alias_value else {
454                continue;
455            };
456            for dep_key in [
457                "extra-deps",
458                "override-deps",
459                "default-deps",
460                "deps",
461                "replace-deps",
462            ] {
463                if let Some(Form::Map(dep_map)) = map_get_keyword(alias_entries, dep_key) {
464                    dependencies.extend(extract_deps_map(dep_map, Some(&alias_name), false));
465                }
466            }
467        }
468        if let Some(json) = form_to_json(
469            &Form::Map(alias_map.clone()),
470            &mut RecursionGuard::depth_only(),
471        ) {
472            extra_data.insert("aliases".to_string(), json);
473        }
474    }
475
476    if let Some(value) = map_get_keyword(entries, "paths")
477        .and_then(|f| form_to_json(f, &mut RecursionGuard::depth_only()))
478    {
479        extra_data.insert("paths".to_string(), value);
480    }
481    if let Some(value) = map_get_keyword(entries, "mvn/repos")
482        .and_then(|f| form_to_json(f, &mut RecursionGuard::depth_only()))
483    {
484        extra_data.insert("mvn_repos".to_string(), value);
485    }
486
487    package.dependencies = dependencies;
488    package.extra_data = (!extra_data.is_empty()).then_some(extra_data);
489    Ok(package)
490}
491
492fn parse_project_clj_form(
493    form: &Form,
494    bindings: &HashMap<String, String>,
495) -> Result<PackageData, String> {
496    let Form::List(items) = form else {
497        return Err("project.clj root is not a list".to_string());
498    };
499    if !matches!(items.first(), Some(Form::Symbol(symbol)) if symbol == "defproject") {
500        return Err("project.clj root is not defproject".to_string());
501    }
502
503    let Some((namespace, name)) = items.get(1).and_then(parse_lib_form) else {
504        return Err("defproject missing project identifier".to_string());
505    };
506
507    // The version sits at position 2 unless it was omitted (options begin
508    // directly). A non-literal version (e.g. `(or (System/getenv ...) "1.2.3")`
509    // or a `~unquoted` `def`) is resolved statically where possible and left
510    // unset otherwise, so the package identity and dependencies are still
511    // recovered rather than discarding the whole manifest.
512    let (version, options_start) = match items.get(2) {
513        Some(form) if !matches!(form, Form::Keyword(_)) => {
514            (resolve_version(form, bindings, true), 3usize)
515        }
516        _ => (None, 2usize),
517    };
518
519    let mut package = default_package_data(Some(DatasourceId::ClojureProjectClj));
520    package.namespace = namespace.clone().map(truncate_field);
521    package.name = Some(truncate_field(name.clone()));
522    package.version = version.as_ref().map(|value| truncate_field(value.clone()));
523    package.purl =
524        build_maven_purl(namespace.as_deref(), &name, version.as_deref()).map(truncate_field);
525
526    let mut index = options_start;
527    while index + 1 < items.len() {
528        let Some(key) = form_as_keyword(&items[index]) else {
529            index += 1;
530            continue;
531        };
532        let value = &items[index + 1];
533
534        match key {
535            "description" => {
536                package.description = form_as_string(value).map(|s| truncate_field(s.to_owned()))
537            }
538            "url" => {
539                package.homepage_url = form_as_string(value).map(|s| truncate_field(s.to_owned()))
540            }
541            "license" => {
542                package.extracted_license_statement =
543                    format_license(value, &mut RecursionGuard::depth_only()).map(truncate_field);
544            }
545            "scm" => {
546                if let Form::Map(entries) = value {
547                    package.vcs_url = map_get_keyword(entries, "url")
548                        .and_then(form_as_string)
549                        .map(|s| truncate_field(s.to_owned()));
550                }
551            }
552            "dependencies" => {
553                if let Form::Vector(deps) = value {
554                    package
555                        .dependencies
556                        .extend(extract_project_dependencies(deps, None, bindings));
557                }
558            }
559            "profiles" => {
560                if let Form::Map(entries) = value {
561                    for (profile_key, profile_value) in entries {
562                        let Some(profile_name) = keyword_or_symbol_name(profile_key) else {
563                            continue;
564                        };
565                        let Form::Map(profile_entries) = profile_value else {
566                            continue;
567                        };
568                        if let Some(Form::Vector(deps)) =
569                            map_get_keyword(profile_entries, "dependencies")
570                        {
571                            package.dependencies.extend(extract_project_dependencies(
572                                deps,
573                                Some(&profile_name),
574                                bindings,
575                            ));
576                        }
577                    }
578                }
579            }
580            _ => {}
581        }
582        index += 2;
583    }
584
585    Ok(package)
586}
587
588fn extract_deps_map(
589    entries: &[(Form, Form)],
590    scope: Option<&str>,
591    runtime: bool,
592) -> Vec<Dependency> {
593    let limit = capped_iteration_limit(entries.len(), "deps.edn deps map");
594    entries
595        .iter()
596        .take(limit)
597        .filter_map(|(lib, coord)| build_deps_edn_dependency(lib, coord, scope, runtime))
598        .collect()
599}
600
601fn build_deps_edn_dependency(
602    lib: &Form,
603    coord: &Form,
604    scope: Option<&str>,
605    runtime: bool,
606) -> Option<Dependency> {
607    let (namespace, raw_name) = parse_lib_form(lib)?;
608    // tools.deps encodes a Maven classifier in the lib symbol as
609    // `artifact$classifier` (e.g. `netty-transport-native-epoll$linux-x86_64`).
610    // Split it out so the purl carries the clean artifact and the classifier
611    // lands in `extra_data`, matching how `project.clj` `:classifier` is stored.
612    let (name, classifier) = match raw_name.split_once('$') {
613        Some((artifact, classifier)) if !artifact.is_empty() && !classifier.is_empty() => {
614            (artifact.to_string(), Some(classifier.to_string()))
615        }
616        _ => (raw_name, None),
617    };
618    let mut extra_data = HashMap::new();
619    if let Some(classifier) = classifier {
620        extra_data.insert("classifier".to_string(), JsonValue::String(classifier));
621    }
622    let mut requirement = None;
623    let mut pinned = false;
624
625    if let Form::Map(entries) = coord {
626        if let Some(version) = map_get_keyword(entries, "mvn/version").and_then(form_as_string) {
627            requirement = Some(version.to_string());
628            pinned = is_exact_version(version);
629        }
630        for (key, data_key) in [
631            ("git/url", "git_url"),
632            ("git/tag", "git_tag"),
633            ("git/sha", "git_sha"),
634            ("deps/root", "deps_root"),
635            ("deps/manifest", "deps_manifest"),
636            ("local/root", "local_root"),
637            ("exclusions", "exclusions"),
638        ] {
639            if let Some(value) = map_get_keyword(entries, key)
640                .and_then(|f| form_to_json(f, &mut RecursionGuard::depth_only()))
641            {
642                extra_data.insert(data_key.to_string(), value);
643            }
644        }
645    }
646
647    Some(Dependency {
648        purl: build_maven_purl(
649            namespace.as_deref(),
650            &name,
651            requirement.as_deref().map(strip_exact_prefix),
652        )
653        .map(truncate_field),
654        extracted_requirement: requirement.map(truncate_field),
655        scope: scope.map(ToOwned::to_owned),
656        is_runtime: Some(runtime),
657        is_optional: Some(scope.is_some()),
658        is_pinned: Some(pinned),
659        is_direct: Some(true),
660        resolved_package: None,
661        extra_data: (!extra_data.is_empty()).then_some(extra_data),
662    })
663}
664
665fn extract_project_dependencies(
666    entries: &[Form],
667    scope: Option<&str>,
668    bindings: &HashMap<String, String>,
669) -> Vec<Dependency> {
670    let limit = capped_iteration_limit(entries.len(), "project.clj dependencies");
671    entries
672        .iter()
673        .take(limit)
674        .filter_map(|entry| {
675            let Form::Vector(parts) = entry else {
676                return None;
677            };
678            let (namespace, name) = parse_lib_form(parts.first()?)?;
679            // Dependency versions live in defproject's quoted body, so only a
680            // `~` unquote (not a bare or `'`-quoted symbol) names a `def` value.
681            let version = resolve_version(parts.get(1)?, bindings, false)?;
682
683            let mut extra_data = HashMap::new();
684            let mut index = 2usize;
685            while index + 1 < parts.len() {
686                if let Some(key) = form_as_keyword(&parts[index])
687                    && let Some(value) =
688                        form_to_json(&parts[index + 1], &mut RecursionGuard::depth_only())
689                {
690                    extra_data.insert(key.replace('-', "_"), value);
691                }
692                index += 2;
693            }
694
695            let (is_runtime, is_optional) = match scope {
696                Some("dev") | Some("test") => (false, true),
697                Some("provided") => (false, false),
698                Some(_) => (false, true),
699                None => (true, false),
700            };
701
702            Some(Dependency {
703                purl: build_maven_purl(
704                    namespace.as_deref(),
705                    &name,
706                    Some(strip_exact_prefix(&version)),
707                )
708                .map(truncate_field),
709                extracted_requirement: Some(truncate_field(version.clone())),
710                scope: scope.map(ToOwned::to_owned),
711                is_runtime: Some(is_runtime),
712                is_optional: Some(is_optional),
713                is_pinned: Some(is_exact_version(&version)),
714                is_direct: Some(true),
715                resolved_package: None,
716                extra_data: (!extra_data.is_empty()).then_some(extra_data),
717            })
718        })
719        .collect()
720}
721
722/// Collect top-level `(def <symbol> "<string literal>")` bindings so that
723/// `~symbol` unquotes and bare-symbol version references elsewhere in the
724/// manifest can be resolved statically, without evaluating any Clojure.
725fn collect_def_bindings(forms: &[Form]) -> HashMap<String, String> {
726    let mut bindings = HashMap::new();
727    for form in forms {
728        let Form::List(items) = form else {
729            continue;
730        };
731        if items.len() != 3 {
732            continue;
733        }
734        if let (Some(Form::Symbol(head)), Some(Form::Symbol(name)), Some(Form::String(value))) =
735            (items.first(), items.get(1), items.get(2))
736            && head == "def"
737        {
738            bindings.insert(name.clone(), value.clone());
739        }
740    }
741    bindings
742}
743
744/// Resolve a version form to a literal string, statically following the simple
745/// indirections real `project.clj` manifests use, without evaluating Clojure.
746///
747/// `evaluated` distinguishes the two positions with different Leiningen
748/// semantics: the `defproject` version slot is evaluated (a bare `symbol` bound
749/// by a `def` resolves), while the quoted `:dependencies` body is not (only a
750/// `~symbol` unquote resolves; a bare or `'`-quoted symbol is literal data).
751/// `(or …)` resolves to its first statically-known argument — matching Clojure's
752/// short-circuit — treating unresolvable arguments (e.g. `(System/getenv …)`) as
753/// unknown and skipping them. Returns `None` when nothing resolves statically.
754fn resolve_version(
755    form: &Form,
756    bindings: &HashMap<String, String>,
757    evaluated: bool,
758) -> Option<String> {
759    match form {
760        Form::String(value) => Some(value.clone()),
761        // A bare symbol only names its `def` value in an evaluated position.
762        Form::Symbol(name) if evaluated => bindings.get(name).cloned(),
763        // `~` unquote forces evaluation of its inner form regardless of context;
764        // any other prefix (`'`, `` ` ``, `@`, `#'`) is quoting, i.e. data.
765        Form::Prefixed('~', inner) => resolve_version(inner, bindings, true),
766        Form::List(items) if matches!(items.first(), Some(Form::Symbol(head)) if head == "or") => {
767            items
768                .iter()
769                .skip(1)
770                .find_map(|arg| resolve_version(arg, bindings, evaluated))
771        }
772        _ => None,
773    }
774}
775
776fn parse_lib_form(form: &Form) -> Option<(Option<String>, String)> {
777    let raw = match form {
778        Form::Symbol(value) | Form::String(value) => value,
779        _ => return None,
780    };
781
782    if let Some((namespace, name)) = raw.split_once('/') {
783        Some((Some(namespace.to_string()), name.to_string()))
784    } else {
785        Some((Some(raw.to_string()), raw.to_string()))
786    }
787}
788
789fn map_get_keyword<'a>(entries: &'a [(Form, Form)], key: &str) -> Option<&'a Form> {
790    entries.iter().find_map(|(entry_key, entry_value)| {
791        if form_as_keyword(entry_key) == Some(key) {
792            Some(entry_value)
793        } else {
794            None
795        }
796    })
797}
798
799fn form_as_keyword(form: &Form) -> Option<&str> {
800    match form {
801        Form::Keyword(value) => Some(value.as_str()),
802        _ => None,
803    }
804}
805
806fn form_as_string(form: &Form) -> Option<&str> {
807    match form {
808        Form::String(value) => Some(value.as_str()),
809        _ => None,
810    }
811}
812
813fn keyword_or_symbol_name(form: &Form) -> Option<String> {
814    match form {
815        Form::Keyword(value) | Form::Symbol(value) => Some(value.clone()),
816        _ => None,
817    }
818}
819
820fn map_key_name(form: &Form) -> Option<String> {
821    match form {
822        Form::Keyword(value) | Form::Symbol(value) | Form::String(value) => Some(value.clone()),
823        _ => None,
824    }
825}
826
827fn form_to_json(form: &Form, guard: &mut RecursionGuard<()>) -> Option<JsonValue> {
828    if guard.descend() {
829        warn!("form_to_json exceeded MAX_RECURSION_DEPTH");
830        return None;
831    }
832    let result = Some(match form {
833        Form::Nil => JsonValue::Null,
834        Form::Bool(value) => JsonValue::Bool(*value),
835        Form::String(value) => JsonValue::String(value.clone()),
836        Form::Keyword(value) => JsonValue::String(format!(":{value}")),
837        Form::Symbol(value) => JsonValue::String(value.clone()),
838        Form::Vector(values) | Form::List(values) => JsonValue::Array(
839            values
840                .iter()
841                .filter_map(|f| form_to_json(f, guard))
842                .collect(),
843        ),
844        Form::Map(entries) => {
845            let mut map = serde_json::Map::new();
846            for (key, value) in entries {
847                let Some(key_name) = map_key_name(key) else {
848                    continue;
849                };
850                if let Some(json) = form_to_json(value, guard) {
851                    map.insert(key_name, json);
852                }
853            }
854            JsonValue::Object(map)
855        }
856        Form::Prefixed(_, value) => form_to_json(value, guard)?,
857    });
858    guard.ascend();
859    result
860}
861
862fn format_license(form: &Form, guard: &mut RecursionGuard<()>) -> Option<String> {
863    if guard.descend() {
864        warn!("format_license exceeded MAX_RECURSION_DEPTH");
865        return None;
866    }
867    let result = match form {
868        Form::Map(entries) => format_license_map(entries),
869        Form::Vector(values) | Form::List(values) => {
870            let licenses: Vec<String> = values
871                .iter()
872                .filter_map(|f| format_license(f, guard))
873                .collect();
874            if licenses.is_empty() {
875                None
876            } else {
877                Some(licenses.join("\n"))
878            }
879        }
880        _ => None,
881    };
882    guard.ascend();
883    result
884}
885
886fn format_license_map(entries: &[(Form, Form)]) -> Option<String> {
887    let name = map_get_keyword(entries, "name").and_then(form_as_string)?;
888    let mut rendered = format!("- license:\n    name: {name}\n");
889    if let Some(url) = map_get_keyword(entries, "url").and_then(form_as_string) {
890        rendered.push_str(&format!("    url: {url}\n"));
891    }
892    Some(rendered)
893}
894
895fn build_maven_purl(namespace: Option<&str>, name: &str, version: Option<&str>) -> Option<String> {
896    let mut purl = PackageUrl::new(PackageType::Maven.as_str(), name).ok()?;
897    if let Some(namespace) = namespace {
898        purl.with_namespace(namespace).ok()?;
899    }
900    if let Some(version) = version {
901        purl.with_version(version).ok()?;
902    }
903    Some(purl.to_string())
904}
905
906fn is_exact_version(version: &str) -> bool {
907    let normalized = strip_exact_prefix(version).trim();
908    !normalized.is_empty()
909        && !normalized.contains('*')
910        && !normalized.contains('^')
911        && !normalized.contains('~')
912        && !normalized.contains('>')
913        && !normalized.contains('<')
914        && !normalized.contains('|')
915        && !normalized.contains(',')
916        && !normalized.contains(' ')
917}
918
919fn strip_exact_prefix(version: &str) -> &str {
920    version.trim_start_matches('=')
921}
922
923fn looks_like_template_project_clj(content: &str) -> bool {
924    let Some(defproject_index) = content.find("(defproject") else {
925        return false;
926    };
927
928    let manifest_window = &content[defproject_index..content.len().min(defproject_index + 256)];
929    manifest_window.contains("{{") && manifest_window.contains("}}")
930}
931
932fn default_package_data(datasource_id: Option<DatasourceId>) -> PackageData {
933    PackageData {
934        package_type: Some(PackageType::Maven),
935        primary_language: Some("Clojure".to_string()),
936        datasource_id,
937        ..Default::default()
938    }
939}