1use crate::external_packages::ResolvedPackage;
4use crate::{Export, Import, Language, Symbol, SymbolKind, Visibility, VisibilityMechanism};
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 has_symbols(&self) -> bool {
23 true
24 }
25
26 fn container_kinds(&self) -> &'static [&'static str] {
27 &["call"] }
29
30 fn function_kinds(&self) -> &'static [&'static str] {
31 &["call"] }
33
34 fn type_kinds(&self) -> &'static [&'static str] {
35 &["call"] }
37
38 fn import_kinds(&self) -> &'static [&'static str] {
39 &["call"] }
41
42 fn public_symbol_kinds(&self) -> &'static [&'static str] {
43 &["call"] }
45
46 fn visibility_mechanism(&self) -> VisibilityMechanism {
47 VisibilityMechanism::NamingConvention }
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 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 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 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"] }
100
101 fn complexity_nodes(&self) -> &'static [&'static str] {
102 &["call", "binary_operator"] }
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}