Skip to main content

normalize_languages/
commonlisp.rs

1//! Common Lisp 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/// Common Lisp language support.
9pub struct CommonLisp;
10
11impl Language for CommonLisp {
12    fn name(&self) -> &'static str {
13        "Common Lisp"
14    }
15    fn extensions(&self) -> &'static [&'static str] {
16        &["lisp", "lsp", "cl", "asd"]
17    }
18    fn grammar_name(&self) -> &'static str {
19        "commonlisp"
20    }
21
22    fn has_symbols(&self) -> bool {
23        true
24    }
25
26    fn container_kinds(&self) -> &'static [&'static str] {
27        &["list_lit"] // (defpackage ...), (defclass ...), etc.
28    }
29
30    fn function_kinds(&self) -> &'static [&'static str] {
31        &["list_lit"] // (defun ...), (defmacro ...), etc.
32    }
33
34    fn type_kinds(&self) -> &'static [&'static str] {
35        &["list_lit"] // (defstruct ...), (defclass ...)
36    }
37
38    fn import_kinds(&self) -> &'static [&'static str] {
39        &["list_lit"] // (require ...), (use-package ...)
40    }
41
42    fn public_symbol_kinds(&self) -> &'static [&'static str] {
43        &["list_lit"]
44    }
45
46    fn visibility_mechanism(&self) -> VisibilityMechanism {
47        VisibilityMechanism::ExplicitExport // (export ...)
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 text = &content[node.byte_range()];
56        let line = node.start_position().row + 1;
57
58        // (defun name ...), (defmacro name ...), etc.
59        for prefix in &["(defun ", "(defmacro ", "(defgeneric ", "(defmethod "] {
60            if text.starts_with(prefix) {
61                if let Some(name) = text[prefix.len()..].split_whitespace().next() {
62                    return vec![Export {
63                        name: name.to_string(),
64                        kind: SymbolKind::Function,
65                        line,
66                    }];
67                }
68            }
69        }
70
71        for prefix in &["(defclass ", "(defstruct "] {
72            if text.starts_with(prefix) {
73                if let Some(name) = text[prefix.len()..].split_whitespace().next() {
74                    return vec![Export {
75                        name: name.to_string(),
76                        kind: SymbolKind::Class,
77                        line,
78                    }];
79                }
80            }
81        }
82
83        Vec::new()
84    }
85
86    fn scope_creating_kinds(&self) -> &'static [&'static str] {
87        &["list_lit"] // let, flet, labels, lambda
88    }
89
90    fn control_flow_kinds(&self) -> &'static [&'static str] {
91        &["list_lit"] // if, cond, case, when, unless
92    }
93
94    fn complexity_nodes(&self) -> &'static [&'static str] {
95        &["list_lit"]
96    }
97
98    fn nesting_nodes(&self) -> &'static [&'static str] {
99        &["list_lit"]
100    }
101
102    fn signature_suffix(&self) -> &'static str {
103        ""
104    }
105
106    fn extract_function(&self, node: &Node, content: &str, _in_container: bool) -> Option<Symbol> {
107        if node.kind() != "list_lit" {
108            return None;
109        }
110
111        let text = &content[node.byte_range()];
112        let first_line = text.lines().next().unwrap_or(text);
113
114        for prefix in &["(defun ", "(defmacro ", "(defgeneric ", "(defmethod "] {
115            if text.starts_with(prefix) {
116                if let Some(name) = text[prefix.len()..].split_whitespace().next() {
117                    return Some(Symbol {
118                        name: name.to_string(),
119                        kind: SymbolKind::Function,
120                        signature: first_line.trim().to_string(),
121                        docstring: self.extract_docstring(node, content),
122                        attributes: Vec::new(),
123                        start_line: node.start_position().row + 1,
124                        end_line: node.end_position().row + 1,
125                        visibility: Visibility::Public,
126                        children: Vec::new(),
127                        is_interface_impl: false,
128                        implements: Vec::new(),
129                    });
130                }
131            }
132        }
133
134        None
135    }
136
137    fn extract_container(&self, node: &Node, content: &str) -> Option<Symbol> {
138        if node.kind() != "list_lit" {
139            return None;
140        }
141
142        let text = &content[node.byte_range()];
143
144        if text.starts_with("(defpackage ") {
145            let name = text["(defpackage ".len()..].split_whitespace().next()?;
146            return Some(Symbol {
147                name: name.to_string(),
148                kind: SymbolKind::Module,
149                signature: format!("(defpackage {})", name),
150                docstring: None,
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        for prefix in &["(defclass ", "(defstruct "] {
162            if text.starts_with(prefix) {
163                let name = text[prefix.len()..].split_whitespace().next()?;
164                return Some(Symbol {
165                    name: name.to_string(),
166                    kind: SymbolKind::Class,
167                    signature: format!("{}{}", prefix.trim_start_matches('('), name),
168                    docstring: self.extract_docstring(node, content),
169                    attributes: Vec::new(),
170                    start_line: node.start_position().row + 1,
171                    end_line: node.end_position().row + 1,
172                    visibility: Visibility::Public,
173                    children: Vec::new(),
174                    is_interface_impl: false,
175                    implements: Vec::new(),
176                });
177            }
178        }
179
180        None
181    }
182
183    fn extract_type(&self, node: &Node, content: &str) -> Option<Symbol> {
184        self.extract_container(node, content)
185    }
186
187    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
188        // Common Lisp docstrings are strings after the argument list
189        let text = &content[node.byte_range()];
190        // Simple heuristic: find first quoted string
191        if let Some(start) = text.find('"')
192            && let Some(end) = text[start + 1..].find('"')
193        {
194            return Some(text[start + 1..start + 1 + end].to_string());
195        }
196        None
197    }
198
199    fn extract_attributes(&self, _node: &Node, _content: &str) -> Vec<String> {
200        Vec::new()
201    }
202
203    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
204        if node.kind() != "list_lit" {
205            return Vec::new();
206        }
207
208        let text = &content[node.byte_range()];
209        let line = node.start_position().row + 1;
210
211        for prefix in &["(require ", "(use-package ", "(ql:quickload "] {
212            if text.starts_with(prefix) {
213                let module = text[prefix.len()..]
214                    .split(|c: char| c.is_whitespace() || c == ')')
215                    .next()
216                    .map(|s| s.trim_matches(|c| c == '\'' || c == ':' || c == '"'))
217                    .unwrap_or("")
218                    .to_string();
219
220                if !module.is_empty() {
221                    return vec![Import {
222                        module,
223                        names: Vec::new(),
224                        alias: None,
225                        is_wildcard: false,
226                        is_relative: false,
227                        line,
228                    }];
229                }
230            }
231        }
232
233        Vec::new()
234    }
235
236    fn format_import(&self, import: &Import, names: Option<&[&str]>) -> String {
237        // Common Lisp: (use-package :package) or (use-package :package (:import-from #:a #:b))
238        let names_to_use: Vec<&str> = names
239            .map(|n| n.to_vec())
240            .unwrap_or_else(|| import.names.iter().map(|s| s.as_str()).collect());
241        if names_to_use.is_empty() {
242            format!("(use-package :{})", import.module)
243        } else {
244            let symbols: Vec<String> = names_to_use.iter().map(|n| format!("#:{}", n)).collect();
245            format!(
246                "(use-package :{} (:import-from {}))",
247                import.module,
248                symbols.join(" ")
249            )
250        }
251    }
252
253    fn is_public(&self, _node: &Node, _content: &str) -> bool {
254        true
255    }
256    fn get_visibility(&self, _node: &Node, _content: &str) -> Visibility {
257        Visibility::Public
258    }
259
260    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
261        let name = symbol.name.as_str();
262        match symbol.kind {
263            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
264            crate::SymbolKind::Module => name == "tests" || name == "test",
265            _ => false,
266        }
267    }
268
269    fn embedded_content(&self, _node: &Node, _content: &str) -> Option<crate::EmbeddedBlock> {
270        None
271    }
272
273    fn container_body<'a>(&self, _node: &'a Node<'a>) -> Option<Node<'a>> {
274        None
275    }
276    fn body_has_docstring(&self, _body: &Node, _content: &str) -> bool {
277        false
278    }
279    fn node_name<'a>(&self, _node: &Node, _content: &'a str) -> Option<&'a str> {
280        None
281    }
282
283    fn file_path_to_module_name(&self, path: &Path) -> Option<String> {
284        let ext = path.extension()?.to_str()?;
285        if !["lisp", "lsp", "cl"].contains(&ext) {
286            return None;
287        }
288        let stem = path.file_stem()?.to_str()?;
289        Some(stem.to_string())
290    }
291
292    fn module_name_to_paths(&self, module: &str) -> Vec<String> {
293        vec![
294            format!("{}.lisp", module),
295            format!("{}.lsp", module),
296            format!("{}.cl", module),
297        ]
298    }
299
300    fn lang_key(&self) -> &'static str {
301        "commonlisp"
302    }
303
304    fn is_stdlib_import(&self, import_name: &str, _project_root: &Path) -> bool {
305        matches!(
306            import_name.to_lowercase().as_str(),
307            "cl" | "common-lisp" | "asdf" | "uiop" | "alexandria" | "cl-ppcre"
308        )
309    }
310
311    fn find_stdlib(&self, _project_root: &Path) -> Option<PathBuf> {
312        None
313    }
314
315    fn resolve_local_import(
316        &self,
317        import: &str,
318        current_file: &Path,
319        _project_root: &Path,
320    ) -> Option<PathBuf> {
321        let dir = current_file.parent()?;
322        for ext in &["lisp", "lsp", "cl"] {
323            let full = dir.join(format!("{}.{}", import, ext));
324            if full.is_file() {
325                return Some(full);
326            }
327        }
328        None
329    }
330
331    fn resolve_external_import(&self, _: &str, _: &Path) -> Option<ResolvedPackage> {
332        None
333    }
334
335    fn get_version(&self, project_root: &Path) -> Option<String> {
336        for entry in std::fs::read_dir(project_root).ok()? {
337            let entry = entry.ok()?;
338            if entry.path().extension().map_or(false, |e| e == "asd") {
339                return Some("ASDF".to_string());
340            }
341        }
342        None
343    }
344
345    fn find_package_cache(&self, _project_root: &Path) -> Option<PathBuf> {
346        if let Some(home) = std::env::var_os("HOME") {
347            let quicklisp = PathBuf::from(home).join("quicklisp/dists");
348            if quicklisp.is_dir() {
349                return Some(quicklisp);
350            }
351        }
352        None
353    }
354
355    fn indexable_extensions(&self) -> &'static [&'static str] {
356        &["lisp", "lsp", "cl"]
357    }
358    fn package_sources(&self, _: &Path) -> Vec<crate::PackageSource> {
359        Vec::new()
360    }
361
362    fn should_skip_package_entry(&self, name: &str, is_dir: bool) -> bool {
363        use crate::traits::{has_extension, skip_dotfiles};
364        if skip_dotfiles(name) {
365            return true;
366        }
367        !is_dir && !has_extension(name, self.indexable_extensions())
368    }
369
370    fn discover_packages(&self, _: &crate::PackageSource) -> Vec<(String, PathBuf)> {
371        Vec::new()
372    }
373
374    fn package_module_name(&self, entry_name: &str) -> String {
375        entry_name
376            .strip_suffix(".lisp")
377            .or_else(|| entry_name.strip_suffix(".lsp"))
378            .or_else(|| entry_name.strip_suffix(".cl"))
379            .unwrap_or(entry_name)
380            .to_string()
381    }
382
383    fn find_package_entry(&self, path: &Path) -> Option<PathBuf> {
384        if path.is_file() {
385            Some(path.to_path_buf())
386        } else {
387            None
388        }
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395    use crate::validate_unused_kinds_audit;
396
397    #[test]
398    fn unused_node_kinds_audit() {
399        #[rustfmt::skip]
400        let documented_unused: &[&str] = &[
401            // Loop-related clauses
402            "accumulation_clause", "condition_clause", "do_clause", "for_clause",
403            "for_clause_word", "loop_clause", "loop_macro", "repeat_clause",
404            "termination_clause", "while_clause", "with_clause",
405            // Format string specifiers
406            "format_directive_type", "format_modifiers", "format_prefix_parameters",
407            "format_specifier",
408            // Comments
409            "block_comment",
410        ];
411        validate_unused_kinds_audit(&CommonLisp, documented_unused)
412            .expect("Common Lisp unused node kinds audit failed");
413    }
414}