1use crate::traits::{ImportSpec, ModuleId, ModuleResolver, Resolution, ResolverConfig};
4use crate::{ContainerBody, Import, Language, LanguageSymbols, Visibility};
5use std::path::{Path, PathBuf};
6use tree_sitter::Node;
7
8pub 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 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 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 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 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 return node
164 .child_by_field_name("name")
165 .map(|n| &content[n.byte_range()]);
166 }
167 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 "alias" => return Some(&content[arg.byte_range()]),
178 "call" => {
180 if let Some(target) = arg.child_by_field_name("target") {
181 return Some(&content[target.byte_range()]);
182 }
183 }
184 "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
202pub struct ElixirModuleResolver;
211
212fn 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
224fn 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 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 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 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 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 "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}