Skip to main content

normalize_languages/
clojure.rs

1//! Clojure language support.
2
3use crate::external_packages::ResolvedPackage;
4use crate::{Export, Import, Language, Symbol, SymbolKind, Visibility, VisibilityMechanism};
5use std::path::{Path, PathBuf};
6use tree_sitter::Node;
7
8/// Clojure language support.
9pub struct Clojure;
10
11impl Language for Clojure {
12    fn name(&self) -> &'static str {
13        "Clojure"
14    }
15    fn extensions(&self) -> &'static [&'static str] {
16        &["clj", "cljs", "cljc", "edn"]
17    }
18    fn grammar_name(&self) -> &'static str {
19        "clojure"
20    }
21
22    fn has_symbols(&self) -> bool {
23        true
24    }
25
26    fn container_kinds(&self) -> &'static [&'static str] {
27        &["list_lit"] // (defn ...), (ns ...), etc.
28    }
29
30    fn function_kinds(&self) -> &'static [&'static str] {
31        &["list_lit"] // (defn name [...] ...)
32    }
33
34    fn type_kinds(&self) -> &'static [&'static str] {
35        &["list_lit"] // (defrecord ...), (defprotocol ...)
36    }
37
38    fn import_kinds(&self) -> &'static [&'static str] {
39        &["list_lit"] // (require ...), (import ...)
40    }
41
42    fn public_symbol_kinds(&self) -> &'static [&'static str] {
43        &["list_lit"]
44    }
45
46    fn visibility_mechanism(&self) -> VisibilityMechanism {
47        VisibilityMechanism::NamingConvention // defn- for private
48    }
49
50    fn extract_public_symbols(&self, node: &Node, content: &str) -> Vec<Export> {
51        if node.kind() != "list_lit" {
52            return Vec::new();
53        }
54
55        let (form, name) = match self.extract_def_form(node, content) {
56            Some(info) => info,
57            None => return Vec::new(),
58        };
59
60        // defn- is private
61        if form == "defn-" || form == "def-" {
62            return Vec::new();
63        }
64
65        let kind = match form.as_str() {
66            "defn" | "defmacro" | "defmethod" => SymbolKind::Function,
67            "defrecord" | "deftype" | "defprotocol" => SymbolKind::Struct,
68            "def" | "defonce" => SymbolKind::Variable,
69            _ => return Vec::new(),
70        };
71
72        vec![Export {
73            name,
74            kind,
75            line: node.start_position().row + 1,
76        }]
77    }
78
79    fn scope_creating_kinds(&self) -> &'static [&'static str] {
80        &["list_lit"] // let, fn, loop, etc.
81    }
82
83    fn control_flow_kinds(&self) -> &'static [&'static str] {
84        &["list_lit"] // if, cond, case, when
85    }
86
87    fn complexity_nodes(&self) -> &'static [&'static str] {
88        &["list_lit"]
89    }
90
91    fn nesting_nodes(&self) -> &'static [&'static str] {
92        &["list_lit", "vec_lit", "map_lit"]
93    }
94
95    fn signature_suffix(&self) -> &'static str {
96        ""
97    }
98
99    fn extract_function(&self, node: &Node, content: &str, _in_container: bool) -> Option<Symbol> {
100        if node.kind() != "list_lit" {
101            return None;
102        }
103
104        let (form, name) = self.extract_def_form(node, content)?;
105
106        if !matches!(form.as_str(), "defn" | "defn-" | "defmacro" | "defmethod") {
107            return None;
108        }
109
110        let text = &content[node.byte_range()];
111        let first_line = text.lines().next().unwrap_or(text);
112
113        Some(Symbol {
114            name,
115            kind: SymbolKind::Function,
116            signature: first_line.trim().to_string(),
117            docstring: self.extract_docstring(node, content),
118            attributes: Vec::new(),
119            start_line: node.start_position().row + 1,
120            end_line: node.end_position().row + 1,
121            visibility: if form == "defn-" {
122                Visibility::Private
123            } else {
124                Visibility::Public
125            },
126            children: Vec::new(),
127            is_interface_impl: false,
128            implements: Vec::new(),
129        })
130    }
131
132    fn extract_container(&self, node: &Node, content: &str) -> Option<Symbol> {
133        if node.kind() != "list_lit" {
134            return None;
135        }
136
137        let (form, name) = self.extract_def_form(node, content)?;
138
139        let kind = match form.as_str() {
140            "ns" => SymbolKind::Module,
141            "defrecord" | "deftype" => SymbolKind::Struct,
142            "defprotocol" => SymbolKind::Interface,
143            _ => return None,
144        };
145
146        Some(Symbol {
147            name: name.clone(),
148            kind,
149            signature: format!("({} {})", form, name),
150            docstring: self.extract_docstring(node, content),
151            attributes: Vec::new(),
152            start_line: node.start_position().row + 1,
153            end_line: node.end_position().row + 1,
154            visibility: Visibility::Public,
155            children: Vec::new(),
156            is_interface_impl: false,
157            implements: Vec::new(),
158        })
159    }
160
161    fn extract_type(&self, node: &Node, content: &str) -> Option<Symbol> {
162        self.extract_container(node, content)
163    }
164
165    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
166        // Clojure docstrings are the third element in defn forms
167        // (defn name "docstring" [...] ...)
168        let mut cursor = node.walk();
169        let children: Vec<_> = node.children(&mut cursor).collect();
170
171        // Skip first (form name) and second (symbol name), check if third is string
172        if children.len() > 2 {
173            let third = &children[2];
174            if third.kind() == "str_lit" {
175                let text = &content[third.byte_range()];
176                return Some(text.trim_matches('"').to_string());
177            }
178        }
179        None
180    }
181
182    fn extract_attributes(&self, _node: &Node, _content: &str) -> Vec<String> {
183        Vec::new()
184    }
185
186    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
187        if node.kind() != "list_lit" {
188            return Vec::new();
189        }
190
191        let (form, _) = match self.extract_def_form(node, content) {
192            Some(info) => info,
193            None => return Vec::new(),
194        };
195
196        if form != "require" && form != "use" && form != "import" {
197            return Vec::new();
198        }
199
200        // Basic extraction - just note the require/import exists
201        vec![Import {
202            module: form,
203            names: Vec::new(),
204            alias: None,
205            is_wildcard: false,
206            is_relative: false,
207            line: node.start_position().row + 1,
208        }]
209    }
210
211    fn format_import(&self, import: &Import, names: Option<&[&str]>) -> String {
212        // Clojure: (require '[namespace]) or (require '[namespace :refer [a b c]])
213        let names_to_use: Vec<&str> = names
214            .map(|n| n.to_vec())
215            .unwrap_or_else(|| import.names.iter().map(|s| s.as_str()).collect());
216        if names_to_use.is_empty() {
217            format!("(require '[{}])", import.module)
218        } else {
219            format!(
220                "(require '[{} :refer [{}]])",
221                import.module,
222                names_to_use.join(" ")
223            )
224        }
225    }
226
227    fn is_public(&self, node: &Node, content: &str) -> bool {
228        if let Some((form, _)) = self.extract_def_form(node, content) {
229            !form.ends_with('-')
230        } else {
231            true
232        }
233    }
234
235    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
236        if self.is_public(node, content) {
237            Visibility::Public
238        } else {
239            Visibility::Private
240        }
241    }
242
243    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
244        let name = symbol.name.as_str();
245        match symbol.kind {
246            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
247            crate::SymbolKind::Module => name == "tests" || name == "test",
248            _ => false,
249        }
250    }
251
252    fn embedded_content(&self, _node: &Node, _content: &str) -> Option<crate::EmbeddedBlock> {
253        None
254    }
255
256    fn container_body<'a>(&self, _node: &'a Node<'a>) -> Option<Node<'a>> {
257        None
258    }
259    fn body_has_docstring(&self, _body: &Node, _content: &str) -> bool {
260        false
261    }
262    fn node_name<'a>(&self, _node: &Node, _content: &'a str) -> Option<&'a str> {
263        None
264    }
265
266    fn file_path_to_module_name(&self, path: &Path) -> Option<String> {
267        let ext = path.extension()?.to_str()?;
268        if !["clj", "cljs", "cljc"].contains(&ext) {
269            return None;
270        }
271        let stem = path.file_stem()?.to_str()?;
272        Some(stem.replace('_', "-"))
273    }
274
275    fn module_name_to_paths(&self, module: &str) -> Vec<String> {
276        let path = module.replace('-', "_").replace('.', "/");
277        vec![
278            format!("{}.clj", path),
279            format!("{}.cljs", path),
280            format!("{}.cljc", path),
281        ]
282    }
283
284    fn lang_key(&self) -> &'static str {
285        "clojure"
286    }
287
288    fn is_stdlib_import(&self, import_name: &str, _project_root: &Path) -> bool {
289        import_name.starts_with("clojure.") || import_name.starts_with("cljs.")
290    }
291
292    fn find_stdlib(&self, _project_root: &Path) -> Option<PathBuf> {
293        None
294    }
295
296    fn resolve_local_import(
297        &self,
298        import: &str,
299        current_file: &Path,
300        _project_root: &Path,
301    ) -> Option<PathBuf> {
302        let dir = current_file.parent()?;
303        let path = import.replace('-', "_").replace('.', "/");
304        for ext in &["clj", "cljs", "cljc"] {
305            let full = dir.join(format!("{}.{}", path, ext));
306            if full.is_file() {
307                return Some(full);
308            }
309        }
310        None
311    }
312
313    fn resolve_external_import(
314        &self,
315        _import_name: &str,
316        _project_root: &Path,
317    ) -> Option<ResolvedPackage> {
318        None
319    }
320
321    fn get_version(&self, project_root: &Path) -> Option<String> {
322        // Check project.clj or deps.edn
323        let project_clj = project_root.join("project.clj");
324        if project_clj.is_file() {
325            return Some("leiningen".to_string());
326        }
327        let deps_edn = project_root.join("deps.edn");
328        if deps_edn.is_file() {
329            return Some("deps.edn".to_string());
330        }
331        None
332    }
333
334    fn find_package_cache(&self, _project_root: &Path) -> Option<PathBuf> {
335        if let Some(home) = std::env::var_os("HOME") {
336            let m2 = PathBuf::from(home).join(".m2/repository");
337            if m2.is_dir() {
338                return Some(m2);
339            }
340        }
341        None
342    }
343
344    fn indexable_extensions(&self) -> &'static [&'static str] {
345        &["clj", "cljs", "cljc"]
346    }
347    fn package_sources(&self, _project_root: &Path) -> Vec<crate::PackageSource> {
348        Vec::new()
349    }
350
351    fn should_skip_package_entry(&self, name: &str, is_dir: bool) -> bool {
352        use crate::traits::{has_extension, skip_dotfiles};
353        if skip_dotfiles(name) {
354            return true;
355        }
356        if is_dir && name == "target" {
357            return true;
358        }
359        !is_dir && !has_extension(name, self.indexable_extensions())
360    }
361
362    fn discover_packages(&self, _source: &crate::PackageSource) -> Vec<(String, PathBuf)> {
363        Vec::new()
364    }
365
366    fn package_module_name(&self, entry_name: &str) -> String {
367        entry_name
368            .strip_suffix(".clj")
369            .or_else(|| entry_name.strip_suffix(".cljs"))
370            .or_else(|| entry_name.strip_suffix(".cljc"))
371            .unwrap_or(entry_name)
372            .replace('_', "-")
373    }
374
375    fn find_package_entry(&self, path: &Path) -> Option<PathBuf> {
376        if path.is_file() {
377            Some(path.to_path_buf())
378        } else {
379            None
380        }
381    }
382}
383
384impl Clojure {
385    /// Extract the form name and symbol name from a list like (defn foo ...)
386    fn extract_def_form(&self, node: &Node, content: &str) -> Option<(String, String)> {
387        let mut cursor = node.walk();
388        let mut form = None;
389        let mut name = None;
390
391        for child in node.children(&mut cursor) {
392            match child.kind() {
393                "sym_lit" if form.is_none() => {
394                    form = Some(content[child.byte_range()].to_string());
395                }
396                "sym_lit" if form.is_some() && name.is_none() => {
397                    name = Some(content[child.byte_range()].to_string());
398                    break;
399                }
400                _ => {}
401            }
402        }
403
404        Some((form?, name?))
405    }
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use crate::validate_unused_kinds_audit;
412
413    #[test]
414    fn unused_node_kinds_audit() {
415        #[rustfmt::skip]
416        let documented_unused: &[&str] = &[];
417        validate_unused_kinds_audit(&Clojure, documented_unused)
418            .expect("Clojure unused node kinds audit failed");
419    }
420}