Skip to main content

normalize_languages/
elixir.rs

1//! Elixir 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/// Elixir language support.
9pub struct Elixir;
10
11impl Language for Elixir {
12    fn name(&self) -> &'static str {
13        "Elixir"
14    }
15    fn extensions(&self) -> &'static [&'static str] {
16        &["ex", "exs"]
17    }
18    fn grammar_name(&self) -> &'static str {
19        "elixir"
20    }
21
22    fn has_symbols(&self) -> bool {
23        true
24    }
25
26    fn container_kinds(&self) -> &'static [&'static str] {
27        &["call"] // defmodule, defprotocol, defimpl
28    }
29
30    fn function_kinds(&self) -> &'static [&'static str] {
31        &["call"] // def, defp, defmacro, defmacrop
32    }
33
34    fn type_kinds(&self) -> &'static [&'static str] {
35        &["call"] // defstruct, @type
36    }
37
38    fn import_kinds(&self) -> &'static [&'static str] {
39        &["call"] // import, alias, require, use
40    }
41
42    fn public_symbol_kinds(&self) -> &'static [&'static str] {
43        &["call"] // def, defmacro (not defp, defmacrop)
44    }
45
46    fn visibility_mechanism(&self) -> VisibilityMechanism {
47        VisibilityMechanism::NamingConvention // def = public, defp = private
48    }
49
50    fn extract_public_symbols(&self, node: &Node, content: &str) -> Vec<Export> {
51        if node.kind() != "call" {
52            return Vec::new();
53        }
54
55        let text = &content[node.byte_range()];
56
57        // Check for def (not defp)
58        if text.starts_with("def ") && !text.starts_with("defp") {
59            if let Some(name) = self.extract_def_name(node, content) {
60                return vec![Export {
61                    name,
62                    kind: SymbolKind::Function,
63                    line: node.start_position().row + 1,
64                }];
65            }
66        }
67
68        // Check for defmacro (not defmacrop)
69        if text.starts_with("defmacro ") && !text.starts_with("defmacrop") {
70            if let Some(name) = self.extract_def_name(node, content) {
71                return vec![Export {
72                    name,
73                    kind: SymbolKind::Function,
74                    line: node.start_position().row + 1,
75                }];
76            }
77        }
78
79        // Check for defmodule
80        if text.starts_with("defmodule ") {
81            if let Some(name) = self.extract_module_name(node, content) {
82                return vec![Export {
83                    name,
84                    kind: SymbolKind::Module,
85                    line: node.start_position().row + 1,
86                }];
87            }
88        }
89
90        Vec::new()
91    }
92
93    fn scope_creating_kinds(&self) -> &'static [&'static str] {
94        &["do_block", "anonymous_function"]
95    }
96
97    fn control_flow_kinds(&self) -> &'static [&'static str] {
98        &["call"] // if, case, cond, with, for, try
99    }
100
101    fn complexity_nodes(&self) -> &'static [&'static str] {
102        &["call", "binary_operator"] // if, case, cond, and/or
103    }
104
105    fn nesting_nodes(&self) -> &'static [&'static str] {
106        &["call", "do_block", "anonymous_function"]
107    }
108
109    fn signature_suffix(&self) -> &'static str {
110        " end"
111    }
112
113    fn extract_function(&self, node: &Node, content: &str, _in_container: bool) -> Option<Symbol> {
114        if node.kind() != "call" {
115            return None;
116        }
117
118        let text = &content[node.byte_range()];
119        let is_private = if text.starts_with("defp ") || text.starts_with("defmacrop ") {
120            true
121        } else if text.starts_with("def ") || text.starts_with("defmacro ") {
122            false
123        } else {
124            return None;
125        };
126
127        let name = self.extract_def_name(node, content)?;
128
129        // Extract first line as signature
130        let first_line = text.lines().next().unwrap_or(text);
131        let signature = first_line.trim_end_matches(" do").to_string();
132
133        Some(Symbol {
134            name,
135            kind: SymbolKind::Function,
136            signature,
137            docstring: self.extract_docstring(node, content),
138            attributes: Vec::new(),
139            start_line: node.start_position().row + 1,
140            end_line: node.end_position().row + 1,
141            visibility: if is_private {
142                Visibility::Private
143            } else {
144                Visibility::Public
145            },
146            children: Vec::new(),
147            is_interface_impl: false,
148            implements: Vec::new(),
149        })
150    }
151
152    fn extract_container(&self, node: &Node, content: &str) -> Option<Symbol> {
153        if node.kind() != "call" {
154            return None;
155        }
156
157        let text = &content[node.byte_range()];
158        if !text.starts_with("defmodule ") {
159            return None;
160        }
161
162        let name = self.extract_module_name(node, content)?;
163
164        Some(Symbol {
165            name: name.clone(),
166            kind: SymbolKind::Module,
167            signature: format!("defmodule {}", 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    fn extract_type(&self, _node: &Node, _content: &str) -> Option<Symbol> {
180        None
181    }
182
183    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
184        // Look for @doc or @moduledoc before the node
185        let mut prev = node.prev_sibling();
186        while let Some(sibling) = prev {
187            let text = &content[sibling.byte_range()];
188            if text.contains("@doc") || text.contains("@moduledoc") {
189                // Extract the string content
190                if let Some(start) = text.find("\"\"\"") {
191                    let rest = &text[start + 3..];
192                    if let Some(end) = rest.find("\"\"\"") {
193                        return Some(rest[..end].trim().to_string());
194                    }
195                }
196                if let Some(start) = text.find('"') {
197                    let rest = &text[start + 1..];
198                    if let Some(end) = rest.find('"') {
199                        return Some(rest[..end].to_string());
200                    }
201                }
202            }
203            prev = sibling.prev_sibling();
204        }
205        None
206    }
207
208    fn extract_attributes(&self, _node: &Node, _content: &str) -> Vec<String> {
209        Vec::new()
210    }
211
212    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
213        if node.kind() != "call" {
214            return Vec::new();
215        }
216
217        let text = &content[node.byte_range()];
218        let line = node.start_position().row + 1;
219
220        // Handle import, alias, require, use
221        for keyword in &["import ", "alias ", "require ", "use "] {
222            if text.starts_with(keyword) {
223                let rest = text[keyword.len()..].trim();
224                let module = rest
225                    .split(|c: char| c.is_whitespace() || c == ',')
226                    .next()
227                    .unwrap_or(rest)
228                    .to_string();
229
230                if !module.is_empty() {
231                    return vec![Import {
232                        module,
233                        names: Vec::new(),
234                        alias: None,
235                        is_wildcard: false,
236                        is_relative: false,
237                        line,
238                    }];
239                }
240            }
241        }
242
243        Vec::new()
244    }
245
246    fn format_import(&self, import: &Import, names: Option<&[&str]>) -> String {
247        // Elixir: import Module or import Module, only: [a: 1, b: 2]
248        let names_to_use: Vec<&str> = names
249            .map(|n| n.to_vec())
250            .unwrap_or_else(|| import.names.iter().map(|s| s.as_str()).collect());
251        if names_to_use.is_empty() {
252            format!("import {}", import.module)
253        } else {
254            format!(
255                "import {}, only: [{}]",
256                import.module,
257                names_to_use.join(", ")
258            )
259        }
260    }
261
262    fn is_public(&self, node: &Node, content: &str) -> bool {
263        if node.kind() != "call" {
264            return false;
265        }
266        let text = &content[node.byte_range()];
267        (text.starts_with("def ") && !text.starts_with("defp"))
268            || (text.starts_with("defmacro ") && !text.starts_with("defmacrop"))
269            || text.starts_with("defmodule ")
270    }
271
272    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
273        if self.is_public(node, content) {
274            Visibility::Public
275        } else {
276            Visibility::Private
277        }
278    }
279
280    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
281        let name = symbol.name.as_str();
282        match symbol.kind {
283            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
284            crate::SymbolKind::Module => name == "tests" || name == "test",
285            _ => false,
286        }
287    }
288
289    fn embedded_content(&self, _node: &Node, _content: &str) -> Option<crate::EmbeddedBlock> {
290        None
291    }
292
293    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
294        // Look for do_block child
295        let mut cursor = node.walk();
296        for child in node.children(&mut cursor) {
297            if child.kind() == "do_block" {
298                return Some(child);
299            }
300        }
301        None
302    }
303
304    fn body_has_docstring(&self, _body: &Node, _content: &str) -> bool {
305        false
306    }
307
308    fn node_name<'a>(&self, _node: &Node, _content: &'a str) -> Option<&'a str> {
309        None
310    }
311
312    fn file_path_to_module_name(&self, path: &Path) -> Option<String> {
313        let ext = path.extension()?.to_str()?;
314        if ext != "ex" && ext != "exs" {
315            return None;
316        }
317        let stem = path.file_stem()?.to_str()?;
318        // Convert snake_case to PascalCase
319        Some(
320            stem.split('_')
321                .map(|s| {
322                    let mut c = s.chars();
323                    match c.next() {
324                        None => String::new(),
325                        Some(f) => f.to_uppercase().chain(c).collect(),
326                    }
327                })
328                .collect::<String>(),
329        )
330    }
331
332    fn module_name_to_paths(&self, module: &str) -> Vec<String> {
333        // Convert PascalCase to snake_case
334        let snake = module
335            .chars()
336            .enumerate()
337            .map(|(i, c)| {
338                if c.is_uppercase() && i > 0 {
339                    format!("_{}", c.to_lowercase())
340                } else {
341                    c.to_lowercase().to_string()
342                }
343            })
344            .collect::<String>();
345
346        vec![format!("lib/{}.ex", snake), format!("{}.ex", snake)]
347    }
348
349    fn lang_key(&self) -> &'static str {
350        "elixir"
351    }
352
353    fn is_stdlib_import(&self, import_name: &str, _project_root: &Path) -> bool {
354        // Elixir stdlib modules
355        matches!(
356            import_name,
357            "Kernel"
358                | "Enum"
359                | "List"
360                | "Map"
361                | "String"
362                | "IO"
363                | "File"
364                | "Path"
365                | "System"
366                | "Process"
367                | "Agent"
368                | "GenServer"
369                | "Supervisor"
370                | "Task"
371                | "Stream"
372                | "Regex"
373                | "DateTime"
374                | "Date"
375                | "Time"
376                | "Integer"
377                | "Float"
378                | "Tuple"
379                | "Keyword"
380                | "Access"
381                | "Protocol"
382                | "Macro"
383                | "Code"
384                | "Module"
385                | "Application"
386                | "Logger"
387                | "Mix"
388        )
389    }
390
391    fn find_stdlib(&self, _project_root: &Path) -> Option<PathBuf> {
392        None
393    }
394
395    fn resolve_local_import(
396        &self,
397        import: &str,
398        _current_file: &Path,
399        project_root: &Path,
400    ) -> Option<PathBuf> {
401        // Convert module name to path
402        let parts: Vec<&str> = import.split('.').collect();
403        let snake_parts: Vec<String> = parts
404            .iter()
405            .map(|p| {
406                p.chars()
407                    .enumerate()
408                    .map(|(i, c)| {
409                        if c.is_uppercase() && i > 0 {
410                            format!("_{}", c.to_lowercase())
411                        } else {
412                            c.to_lowercase().to_string()
413                        }
414                    })
415                    .collect::<String>()
416            })
417            .collect();
418
419        let path = snake_parts.join("/");
420        let full = project_root.join("lib").join(format!("{}.ex", path));
421        if full.is_file() {
422            return Some(full);
423        }
424
425        None
426    }
427
428    fn resolve_external_import(
429        &self,
430        _import_name: &str,
431        _project_root: &Path,
432    ) -> Option<ResolvedPackage> {
433        // Hex package resolution would go here
434        None
435    }
436
437    fn get_version(&self, project_root: &Path) -> Option<String> {
438        let mix_exs = project_root.join("mix.exs");
439        if mix_exs.is_file() {
440            if let Ok(content) = std::fs::read_to_string(&mix_exs) {
441                // Look for version: "x.y.z"
442                for line in content.lines() {
443                    if line.contains("version:") && line.contains('"') {
444                        if let Some(start) = line.find('"') {
445                            let rest = &line[start + 1..];
446                            if let Some(end) = rest.find('"') {
447                                return Some(rest[..end].to_string());
448                            }
449                        }
450                    }
451                }
452            }
453        }
454        None
455    }
456
457    fn find_package_cache(&self, project_root: &Path) -> Option<PathBuf> {
458        let deps = project_root.join("deps");
459        if deps.is_dir() {
460            return Some(deps);
461        }
462        None
463    }
464
465    fn indexable_extensions(&self) -> &'static [&'static str] {
466        &["ex"]
467    }
468    fn package_sources(&self, _project_root: &Path) -> Vec<crate::PackageSource> {
469        Vec::new()
470    }
471
472    fn should_skip_package_entry(&self, name: &str, is_dir: bool) -> bool {
473        use crate::traits::{has_extension, skip_dotfiles};
474        if skip_dotfiles(name) {
475            return true;
476        }
477        if is_dir && (name == "_build" || name == "deps" || name == ".elixir_ls") {
478            return true;
479        }
480        !is_dir && !has_extension(name, self.indexable_extensions())
481    }
482
483    fn discover_packages(&self, _source: &crate::PackageSource) -> Vec<(String, PathBuf)> {
484        Vec::new()
485    }
486
487    fn package_module_name(&self, entry_name: &str) -> String {
488        entry_name
489            .strip_suffix(".ex")
490            .or_else(|| entry_name.strip_suffix(".exs"))
491            .unwrap_or(entry_name)
492            .to_string()
493    }
494
495    fn find_package_entry(&self, path: &Path) -> Option<PathBuf> {
496        if path.is_file() {
497            return Some(path.to_path_buf());
498        }
499        let lib = path
500            .join("lib")
501            .join(format!("{}.ex", path.file_name()?.to_str()?));
502        if lib.is_file() {
503            return Some(lib);
504        }
505        None
506    }
507}
508
509impl Elixir {
510    fn extract_def_name(&self, node: &Node, content: &str) -> Option<String> {
511        // Look for the function name after def/defp
512        let mut cursor = node.walk();
513        for child in node.children(&mut cursor) {
514            if child.kind() == "call" || child.kind() == "identifier" {
515                let text = &content[child.byte_range()];
516                // Extract just the name (before parentheses)
517                let name = text.split('(').next().unwrap_or(text).trim();
518                if !name.is_empty()
519                    && name != "def"
520                    && name != "defp"
521                    && name != "defmacro"
522                    && name != "defmacrop"
523                {
524                    return Some(name.to_string());
525                }
526            }
527        }
528        None
529    }
530
531    fn extract_module_name(&self, node: &Node, content: &str) -> Option<String> {
532        // Look for the module name after defmodule
533        let mut cursor = node.walk();
534        for child in node.children(&mut cursor) {
535            if child.kind() == "alias" || child.kind() == "atom" {
536                let text = &content[child.byte_range()];
537                if !text.is_empty() && text != "defmodule" {
538                    return Some(text.to_string());
539                }
540            }
541        }
542        None
543    }
544}
545
546#[cfg(test)]
547mod tests {
548    use super::*;
549    use crate::validate_unused_kinds_audit;
550
551    #[test]
552    fn unused_node_kinds_audit() {
553        #[rustfmt::skip]
554        let documented_unused: &[&str] = &[
555            "after_block", "block", "body", "catch_block", "charlist",
556            "else_block", "identifier", "interpolation", "operator_identifier",
557            "rescue_block", "sigil_modifiers", "stab_clause", "struct",
558            "unary_operator",
559        ];
560        validate_unused_kinds_audit(&Elixir, documented_unused)
561            .expect("Elixir unused node kinds audit failed");
562    }
563}