Skip to main content

normalize_languages/
elixir.rs

1//! Elixir language support.
2
3use crate::traits::{ImportSpec, ModuleId, ModuleResolver, Resolution, ResolverConfig};
4use crate::{ContainerBody, Import, Language, LanguageSymbols, Visibility};
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 as_symbols(&self) -> Option<&dyn LanguageSymbols> {
23        Some(self)
24    }
25
26    fn signature_suffix(&self) -> &'static str {
27        " end"
28    }
29
30    fn extract_attributes(&self, node: &Node, content: &str) -> Vec<String> {
31        let mut attrs = Vec::new();
32        let mut prev = node.prev_sibling();
33        while let Some(sibling) = prev {
34            if sibling.kind() == "unary_operator" {
35                let text = content[sibling.byte_range()].trim();
36                if text.starts_with('@')
37                    && !text.starts_with("@doc")
38                    && !text.starts_with("@moduledoc")
39                {
40                    attrs.insert(0, text.to_string());
41                }
42                prev = sibling.prev_sibling();
43            } else {
44                break;
45            }
46        }
47        attrs
48    }
49
50    fn build_signature(&self, node: &Node, content: &str) -> String {
51        if node.kind() != "call" {
52            let text = &content[node.byte_range()];
53            return text.lines().next().unwrap_or(text).trim().to_string();
54        }
55        let text = &content[node.byte_range()];
56        if text.starts_with("defmodule ")
57            && let Some(name) = self.extract_module_name(node, content)
58        {
59            return format!("defmodule {}", name);
60        }
61        // For def/defp/defmacro: take first line, trim trailing " do"
62        let first_line = text.lines().next().unwrap_or(text).trim();
63        first_line.trim_end_matches(" do").to_string()
64    }
65
66    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
67        if node.kind() != "call" {
68            return Vec::new();
69        }
70
71        let text = &content[node.byte_range()];
72        let line = node.start_position().row + 1;
73
74        // Handle import, alias, require, use
75        for keyword in &["import ", "alias ", "require ", "use "] {
76            if let Some(stripped) = text.strip_prefix(keyword) {
77                let rest = stripped.trim();
78                let module = rest
79                    .split(|c: char| c.is_whitespace() || c == ',')
80                    .next()
81                    .unwrap_or(rest)
82                    .to_string();
83
84                if !module.is_empty() {
85                    return vec![Import {
86                        module,
87                        names: Vec::new(),
88                        alias: None,
89                        is_wildcard: false,
90                        is_relative: false,
91                        line,
92                    }];
93                }
94            }
95        }
96
97        Vec::new()
98    }
99
100    fn format_import(&self, import: &Import, names: Option<&[&str]>) -> String {
101        // Elixir: import Module or import Module, only: [a: 1, b: 2]
102        let names_to_use: Vec<&str> = names
103            .map(|n| n.to_vec())
104            .unwrap_or_else(|| import.names.iter().map(|s| s.as_str()).collect());
105        if names_to_use.is_empty() {
106            format!("import {}", import.module)
107        } else {
108            format!(
109                "import {}, only: [{}]",
110                import.module,
111                names_to_use.join(", ")
112            )
113        }
114    }
115
116    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
117        if node.kind() != "call" {
118            return Visibility::Private;
119        }
120        let text = &content[node.byte_range()];
121        let is_public = (text.starts_with("def ") && !text.starts_with("defp"))
122            || (text.starts_with("defmacro ") && !text.starts_with("defmacrop"))
123            || text.starts_with("defmodule ");
124        if is_public {
125            Visibility::Public
126        } else {
127            Visibility::Private
128        }
129    }
130
131    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
132        let name = symbol.name.as_str();
133        match symbol.kind {
134            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
135            crate::SymbolKind::Module => name == "tests" || name == "test",
136            _ => false,
137        }
138    }
139
140    fn test_file_globs(&self) -> &'static [&'static str] {
141        &["**/test/**/*.exs", "**/*_test.exs"]
142    }
143
144    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
145        // Look for do_block child
146        let mut cursor = node.walk();
147        node.children(&mut cursor)
148            .find(|&child| child.kind() == "do_block")
149    }
150
151    fn analyze_container_body(
152        &self,
153        body_node: &Node,
154        content: &str,
155        inner_indent: &str,
156    ) -> Option<ContainerBody> {
157        crate::body::analyze_do_end_body(body_node, content, inner_indent)
158    }
159
160    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
161        if node.kind() != "call" {
162            // Fall back to default (child_by_field_name("name"))
163            return node
164                .child_by_field_name("name")
165                .map(|n| &content[n.byte_range()]);
166        }
167        // For Elixir call nodes (def/defp/defmodule/defmacro/defprotocol/defimpl):
168        // - defmodule MathUtils → arguments > alias
169        // - def add(a, b) → arguments > call > target > identifier
170        let mut cursor = node.walk();
171        for child in node.children(&mut cursor) {
172            if child.kind() == "arguments" {
173                let mut arg_cursor = child.walk();
174                for arg in child.children(&mut arg_cursor) {
175                    match arg.kind() {
176                        // defmodule/defprotocol/defimpl: (arguments (alias) ...)
177                        "alias" => return Some(&content[arg.byte_range()]),
178                        // def/defp/defmacro: (arguments (call target: (identifier) ...) ...)
179                        "call" => {
180                            if let Some(target) = arg.child_by_field_name("target") {
181                                return Some(&content[target.byte_range()]);
182                            }
183                        }
184                        // def with no args: (arguments (identifier) ...)
185                        "identifier" => return Some(&content[arg.byte_range()]),
186                        _ => {}
187                    }
188                }
189            }
190        }
191        None
192    }
193
194    fn module_resolver(&self) -> Option<&dyn ModuleResolver> {
195        static RESOLVER: ElixirModuleResolver = ElixirModuleResolver;
196        Some(&RESOLVER)
197    }
198}
199
200impl LanguageSymbols for Elixir {}
201
202// =============================================================================
203// Elixir Module Resolver
204// =============================================================================
205
206/// Module resolver for Elixir (Mix conventions).
207///
208/// Mix convention: `lib/my_app/utils.ex` contains module `MyApp.Utils`.
209/// Converts CamelCase module name ↔ snake_case path.
210pub struct ElixirModuleResolver;
211
212/// Convert a CamelCase module name component to snake_case.
213fn camel_to_snake(s: &str) -> String {
214    let mut out = String::new();
215    for (i, c) in s.chars().enumerate() {
216        if c.is_uppercase() && i > 0 {
217            out.push('_');
218        }
219        out.push(c.to_lowercase().next().unwrap_or(c));
220    }
221    out
222}
223
224/// Convert a snake_case path segment to CamelCase.
225fn snake_to_camel(s: &str) -> String {
226    s.split('_')
227        .map(|word| {
228            let mut chars = word.chars();
229            match chars.next() {
230                None => String::new(),
231                Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
232            }
233        })
234        .collect()
235}
236
237impl ModuleResolver for ElixirModuleResolver {
238    fn workspace_config(&self, root: &Path) -> ResolverConfig {
239        let mut path_mappings: Vec<(String, PathBuf)> = Vec::new();
240
241        let mix_exs = root.join("mix.exs");
242        if let Ok(content) = std::fs::read_to_string(&mix_exs) {
243            // Parse `app: :my_app`
244            for line in content.lines() {
245                let trimmed = line.trim();
246                if let Some(rest) = trimmed.strip_prefix("app:") {
247                    let rest = rest.trim();
248                    let app_atom = rest
249                        .trim_start_matches(':')
250                        .split(',')
251                        .next()
252                        .unwrap_or("")
253                        .trim();
254                    if !app_atom.is_empty() {
255                        // Convert my_app → MyApp for the module prefix
256                        let module_prefix = snake_to_camel(app_atom);
257                        path_mappings.push((module_prefix, root.join("lib")));
258                        break;
259                    }
260                }
261            }
262        }
263
264        ResolverConfig {
265            workspace_root: root.to_path_buf(),
266            path_mappings,
267            search_roots: vec![root.join("lib"), root.join("test")],
268        }
269    }
270
271    fn module_of_file(&self, _root: &Path, file: &Path, cfg: &ResolverConfig) -> Vec<ModuleId> {
272        let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
273        if ext != "ex" && ext != "exs" {
274            return Vec::new();
275        }
276        for search_root in &cfg.search_roots {
277            if let Ok(rel) = file.strip_prefix(search_root) {
278                let module_path: String = rel
279                    .to_str()
280                    .unwrap_or("")
281                    .trim_end_matches(".exs")
282                    .trim_end_matches(".ex")
283                    .split('/')
284                    .map(snake_to_camel)
285                    .collect::<Vec<_>>()
286                    .join(".");
287                if !module_path.is_empty() {
288                    return vec![ModuleId {
289                        canonical_path: module_path,
290                    }];
291                }
292            }
293        }
294        Vec::new()
295    }
296
297    fn resolve(&self, from_file: &Path, spec: &ImportSpec, cfg: &ResolverConfig) -> Resolution {
298        let ext = from_file.extension().and_then(|e| e.to_str()).unwrap_or("");
299        if ext != "ex" && ext != "exs" {
300            return Resolution::NotApplicable;
301        }
302        let raw = &spec.raw;
303        // Convert MyApp.Utils → my_app/utils.ex
304        let path_part = raw
305            .split('.')
306            .map(camel_to_snake)
307            .collect::<Vec<_>>()
308            .join("/");
309        let exported_name = raw.rsplit('.').next().unwrap_or(raw).to_string();
310
311        for search_root in &cfg.search_roots {
312            for ext_try in &["ex", "exs"] {
313                let candidate = search_root.join(format!("{}.{}", path_part, ext_try));
314                if candidate.exists() {
315                    return Resolution::Resolved(candidate, exported_name);
316                }
317            }
318        }
319        Resolution::NotFound
320    }
321}
322
323impl Elixir {
324    fn extract_module_name(&self, node: &Node, content: &str) -> Option<String> {
325        // Look for the module name after defmodule
326        let mut cursor = node.walk();
327        for child in node.children(&mut cursor) {
328            if child.kind() == "alias" || child.kind() == "atom" {
329                let text = &content[child.byte_range()];
330                if !text.is_empty() && text != "defmodule" {
331                    return Some(text.to_string());
332                }
333            }
334        }
335        None
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::validate_unused_kinds_audit;
343
344    #[test]
345    fn unused_node_kinds_audit() {
346        #[rustfmt::skip]
347        let documented_unused: &[&str] = &[
348            "after_block", "block", "body", "catch_block", "charlist",
349            "else_block", "interpolation", "operator_identifier",
350            "rescue_block", "sigil_modifiers", "stab_clause", "struct",
351            "unary_operator",
352            // control flow — not extracted as symbols
353            "binary_operator",
354            "do_block",
355            "anonymous_function",
356        ];
357        validate_unused_kinds_audit(&Elixir, documented_unused)
358            .expect("Elixir unused node kinds audit failed");
359    }
360}