Skip to main content

normalize_languages/
elixir.rs

1//! Elixir language support.
2
3use crate::{ContainerBody, Import, Language, LanguageSymbols, Visibility};
4use tree_sitter::Node;
5
6/// Elixir language support.
7pub struct Elixir;
8
9impl Language for Elixir {
10    fn name(&self) -> &'static str {
11        "Elixir"
12    }
13    fn extensions(&self) -> &'static [&'static str] {
14        &["ex", "exs"]
15    }
16    fn grammar_name(&self) -> &'static str {
17        "elixir"
18    }
19
20    fn as_symbols(&self) -> Option<&dyn LanguageSymbols> {
21        Some(self)
22    }
23
24    fn signature_suffix(&self) -> &'static str {
25        " end"
26    }
27
28    fn extract_attributes(&self, node: &Node, content: &str) -> Vec<String> {
29        let mut attrs = Vec::new();
30        let mut prev = node.prev_sibling();
31        while let Some(sibling) = prev {
32            if sibling.kind() == "unary_operator" {
33                let text = content[sibling.byte_range()].trim();
34                if text.starts_with('@')
35                    && !text.starts_with("@doc")
36                    && !text.starts_with("@moduledoc")
37                {
38                    attrs.insert(0, text.to_string());
39                }
40                prev = sibling.prev_sibling();
41            } else {
42                break;
43            }
44        }
45        attrs
46    }
47
48    fn build_signature(&self, node: &Node, content: &str) -> String {
49        if node.kind() != "call" {
50            let text = &content[node.byte_range()];
51            return text.lines().next().unwrap_or(text).trim().to_string();
52        }
53        let text = &content[node.byte_range()];
54        if text.starts_with("defmodule ")
55            && let Some(name) = self.extract_module_name(node, content)
56        {
57            return format!("defmodule {}", name);
58        }
59        // For def/defp/defmacro: take first line, trim trailing " do"
60        let first_line = text.lines().next().unwrap_or(text).trim();
61        first_line.trim_end_matches(" do").to_string()
62    }
63
64    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
65        if node.kind() != "call" {
66            return Vec::new();
67        }
68
69        let text = &content[node.byte_range()];
70        let line = node.start_position().row + 1;
71
72        // Handle import, alias, require, use
73        for keyword in &["import ", "alias ", "require ", "use "] {
74            if let Some(stripped) = text.strip_prefix(keyword) {
75                let rest = stripped.trim();
76                let module = rest
77                    .split(|c: char| c.is_whitespace() || c == ',')
78                    .next()
79                    .unwrap_or(rest)
80                    .to_string();
81
82                if !module.is_empty() {
83                    return vec![Import {
84                        module,
85                        names: Vec::new(),
86                        alias: None,
87                        is_wildcard: false,
88                        is_relative: false,
89                        line,
90                    }];
91                }
92            }
93        }
94
95        Vec::new()
96    }
97
98    fn format_import(&self, import: &Import, names: Option<&[&str]>) -> String {
99        // Elixir: import Module or import Module, only: [a: 1, b: 2]
100        let names_to_use: Vec<&str> = names
101            .map(|n| n.to_vec())
102            .unwrap_or_else(|| import.names.iter().map(|s| s.as_str()).collect());
103        if names_to_use.is_empty() {
104            format!("import {}", import.module)
105        } else {
106            format!(
107                "import {}, only: [{}]",
108                import.module,
109                names_to_use.join(", ")
110            )
111        }
112    }
113
114    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
115        if node.kind() != "call" {
116            return Visibility::Private;
117        }
118        let text = &content[node.byte_range()];
119        let is_public = (text.starts_with("def ") && !text.starts_with("defp"))
120            || (text.starts_with("defmacro ") && !text.starts_with("defmacrop"))
121            || text.starts_with("defmodule ");
122        if is_public {
123            Visibility::Public
124        } else {
125            Visibility::Private
126        }
127    }
128
129    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
130        let name = symbol.name.as_str();
131        match symbol.kind {
132            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
133            crate::SymbolKind::Module => name == "tests" || name == "test",
134            _ => false,
135        }
136    }
137
138    fn test_file_globs(&self) -> &'static [&'static str] {
139        &["**/test/**/*.exs", "**/*_test.exs"]
140    }
141
142    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
143        // Look for do_block child
144        let mut cursor = node.walk();
145        node.children(&mut cursor)
146            .find(|&child| child.kind() == "do_block")
147    }
148
149    fn analyze_container_body(
150        &self,
151        body_node: &Node,
152        content: &str,
153        inner_indent: &str,
154    ) -> Option<ContainerBody> {
155        crate::body::analyze_do_end_body(body_node, content, inner_indent)
156    }
157
158    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
159        if node.kind() != "call" {
160            // Fall back to default (child_by_field_name("name"))
161            return node
162                .child_by_field_name("name")
163                .map(|n| &content[n.byte_range()]);
164        }
165        // For Elixir call nodes (def/defp/defmodule/defmacro/defprotocol/defimpl):
166        // - defmodule MathUtils → arguments > alias
167        // - def add(a, b) → arguments > call > target > identifier
168        let mut cursor = node.walk();
169        for child in node.children(&mut cursor) {
170            if child.kind() == "arguments" {
171                let mut arg_cursor = child.walk();
172                for arg in child.children(&mut arg_cursor) {
173                    match arg.kind() {
174                        // defmodule/defprotocol/defimpl: (arguments (alias) ...)
175                        "alias" => return Some(&content[arg.byte_range()]),
176                        // def/defp/defmacro: (arguments (call target: (identifier) ...) ...)
177                        "call" => {
178                            if let Some(target) = arg.child_by_field_name("target") {
179                                return Some(&content[target.byte_range()]);
180                            }
181                        }
182                        // def with no args: (arguments (identifier) ...)
183                        "identifier" => return Some(&content[arg.byte_range()]),
184                        _ => {}
185                    }
186                }
187            }
188        }
189        None
190    }
191}
192
193impl LanguageSymbols for Elixir {}
194
195impl Elixir {
196    fn extract_module_name(&self, node: &Node, content: &str) -> Option<String> {
197        // Look for the module name after defmodule
198        let mut cursor = node.walk();
199        for child in node.children(&mut cursor) {
200            if child.kind() == "alias" || child.kind() == "atom" {
201                let text = &content[child.byte_range()];
202                if !text.is_empty() && text != "defmodule" {
203                    return Some(text.to_string());
204                }
205            }
206        }
207        None
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::validate_unused_kinds_audit;
215
216    #[test]
217    fn unused_node_kinds_audit() {
218        #[rustfmt::skip]
219        let documented_unused: &[&str] = &[
220            "after_block", "block", "body", "catch_block", "charlist",
221            "else_block", "interpolation", "operator_identifier",
222            "rescue_block", "sigil_modifiers", "stab_clause", "struct",
223            "unary_operator",
224            // control flow — not extracted as symbols
225            "binary_operator",
226            "do_block",
227            "anonymous_function",
228        ];
229        validate_unused_kinds_audit(&Elixir, documented_unused)
230            .expect("Elixir unused node kinds audit failed");
231    }
232}