Skip to main content

normalize_languages/
erlang.rs

1//! Erlang 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/// Erlang language support.
9pub struct Erlang;
10
11impl Language for Erlang {
12    fn name(&self) -> &'static str {
13        "Erlang"
14    }
15    fn extensions(&self) -> &'static [&'static str] {
16        &["erl", "hrl"]
17    }
18    fn grammar_name(&self) -> &'static str {
19        "erlang"
20    }
21
22    fn has_symbols(&self) -> bool {
23        true
24    }
25
26    fn container_kinds(&self) -> &'static [&'static str] {
27        &["module_attribute"] // -module(name).
28    }
29
30    fn function_kinds(&self) -> &'static [&'static str] {
31        &["function_clause"]
32    }
33
34    fn type_kinds(&self) -> &'static [&'static str] {
35        &["type_alias", "record_decl"]
36    }
37
38    fn import_kinds(&self) -> &'static [&'static str] {
39        &["module_attribute"] // -import(module, [...]).
40    }
41
42    fn public_symbol_kinds(&self) -> &'static [&'static str] {
43        &["function_clause"] // Only exported functions are public
44    }
45
46    fn visibility_mechanism(&self) -> VisibilityMechanism {
47        VisibilityMechanism::ExplicitExport // -export([...]).
48    }
49
50    fn extract_public_symbols(&self, node: &Node, content: &str) -> Vec<Export> {
51        // Functions are only public if listed in -export
52        // For now, return all functions as we'd need module-level analysis
53        if node.kind() == "function_clause" {
54            if let Some(name) = self.node_name(node, content) {
55                return vec![Export {
56                    name: name.to_string(),
57                    kind: SymbolKind::Function,
58                    line: node.start_position().row + 1,
59                }];
60            }
61        }
62        Vec::new()
63    }
64
65    fn scope_creating_kinds(&self) -> &'static [&'static str] {
66        &[
67            "case_expr",
68            "if_expr",
69            "receive_expr",
70            "try_expr",
71            "fun_clause",
72        ]
73    }
74
75    fn control_flow_kinds(&self) -> &'static [&'static str] {
76        &["case_expr", "if_expr", "receive_expr", "try_expr"]
77    }
78
79    fn complexity_nodes(&self) -> &'static [&'static str] {
80        &["cr_clause", "if_clause", "catch_clause", "guard"]
81    }
82
83    fn nesting_nodes(&self) -> &'static [&'static str] {
84        &[
85            "case_expr",
86            "if_expr",
87            "receive_expr",
88            "try_expr",
89            "function_clause",
90            "fun_clause",
91        ]
92    }
93
94    fn signature_suffix(&self) -> &'static str {
95        ""
96    }
97
98    fn extract_function(&self, node: &Node, content: &str, _in_container: bool) -> Option<Symbol> {
99        if node.kind() != "function_clause" {
100            return None;
101        }
102
103        let name = self.node_name(node, content)?;
104
105        // Get arity from parameters
106        let arity = node
107            .child_by_field_name("arguments")
108            .map(|args| {
109                let mut cursor = args.walk();
110                args.children(&mut cursor).count()
111            })
112            .unwrap_or(0);
113
114        let signature = format!("{}/{}", name, arity);
115
116        Some(Symbol {
117            name: name.to_string(),
118            kind: SymbolKind::Function,
119            signature,
120            docstring: self.extract_docstring(node, content),
121            attributes: Vec::new(),
122            start_line: node.start_position().row + 1,
123            end_line: node.end_position().row + 1,
124            visibility: Visibility::Public, // Would need export analysis for accuracy
125            children: Vec::new(),
126            is_interface_impl: false,
127            implements: Vec::new(),
128        })
129    }
130
131    fn extract_container(&self, node: &Node, content: &str) -> Option<Symbol> {
132        if node.kind() != "module_attribute" {
133            return None;
134        }
135
136        let text = &content[node.byte_range()];
137        if !text.starts_with("-module(") {
138            return None;
139        }
140
141        // Extract module name from -module(name).
142        if let Some(start) = text.find('(') {
143            let rest = &text[start + 1..];
144            if let Some(end) = rest.find(')') {
145                let name = rest[..end].trim().to_string();
146                return Some(Symbol {
147                    name: name.clone(),
148                    kind: SymbolKind::Module,
149                    signature: format!("-module({}).", name),
150                    docstring: None,
151                    attributes: Vec::new(),
152                    start_line: node.start_position().row + 1,
153                    end_line: node.end_position().row + 1,
154                    visibility: Visibility::Public,
155                    children: Vec::new(),
156                    is_interface_impl: false,
157                    implements: Vec::new(),
158                });
159            }
160        }
161
162        None
163    }
164
165    fn extract_type(&self, node: &Node, content: &str) -> Option<Symbol> {
166        if node.kind() != "type_alias" && node.kind() != "record_decl" {
167            return None;
168        }
169
170        let name = self.node_name(node, content)?;
171        let kind = if node.kind() == "record_decl" {
172            SymbolKind::Struct
173        } else {
174            SymbolKind::Type
175        };
176
177        Some(Symbol {
178            name: name.to_string(),
179            kind,
180            signature: content[node.byte_range()].lines().next()?.to_string(),
181            docstring: None,
182            attributes: Vec::new(),
183            start_line: node.start_position().row + 1,
184            end_line: node.end_position().row + 1,
185            visibility: Visibility::Public,
186            children: Vec::new(),
187            is_interface_impl: false,
188            implements: Vec::new(),
189        })
190    }
191
192    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
193        // Erlang uses %% or %%% for documentation comments
194        let mut prev = node.prev_sibling();
195        let mut doc_lines = Vec::new();
196
197        while let Some(sibling) = prev {
198            let text = &content[sibling.byte_range()];
199            if sibling.kind() == "comment" && text.starts_with("%%") {
200                let line = text.trim_start_matches('%').trim();
201                if !line.starts_with('@') {
202                    doc_lines.push(line.to_string());
203                }
204                prev = sibling.prev_sibling();
205            } else {
206                break;
207            }
208        }
209
210        if doc_lines.is_empty() {
211            return None;
212        }
213
214        doc_lines.reverse();
215        Some(doc_lines.join(" "))
216    }
217
218    fn extract_attributes(&self, _node: &Node, _content: &str) -> Vec<String> {
219        Vec::new()
220    }
221
222    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
223        if node.kind() != "module_attribute" {
224            return Vec::new();
225        }
226
227        let text = &content[node.byte_range()];
228        let line = node.start_position().row + 1;
229
230        // Handle -import(module, [...]).
231        if text.starts_with("-import(") {
232            if let Some(start) = text.find('(') {
233                let rest = &text[start + 1..];
234                if let Some(comma) = rest.find(',') {
235                    let module = rest[..comma].trim().to_string();
236                    return vec![Import {
237                        module,
238                        names: Vec::new(),
239                        alias: None,
240                        is_wildcard: false,
241                        is_relative: false,
242                        line,
243                    }];
244                }
245            }
246        }
247
248        // Handle -include("file.hrl"). or -include_lib("app/include/file.hrl").
249        if text.starts_with("-include") {
250            if let Some(start) = text.find('"') {
251                let rest = &text[start + 1..];
252                if let Some(end) = rest.find('"') {
253                    let module = rest[..end].to_string();
254                    return vec![Import {
255                        module,
256                        names: Vec::new(),
257                        alias: None,
258                        is_wildcard: false,
259                        is_relative: text.starts_with("-include("),
260                        line,
261                    }];
262                }
263            }
264        }
265
266        Vec::new()
267    }
268
269    fn format_import(&self, import: &Import, names: Option<&[&str]>) -> String {
270        // Erlang: -import(module, [func/arity, ...]).
271        let names_to_use: Vec<&str> = names
272            .map(|n| n.to_vec())
273            .unwrap_or_else(|| import.names.iter().map(|s| s.as_str()).collect());
274        if names_to_use.is_empty() {
275            format!("-import({}, []).", import.module)
276        } else {
277            format!("-import({}, [{}]).", import.module, names_to_use.join(", "))
278        }
279    }
280
281    fn is_public(&self, _node: &Node, _content: &str) -> bool {
282        // Would need module-level export analysis
283        true
284    }
285
286    fn get_visibility(&self, _node: &Node, _content: &str) -> Visibility {
287        Visibility::Public
288    }
289
290    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
291        let name = symbol.name.as_str();
292        match symbol.kind {
293            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
294            crate::SymbolKind::Module => name == "tests" || name == "test",
295            _ => false,
296        }
297    }
298
299    fn embedded_content(&self, _node: &Node, _content: &str) -> Option<crate::EmbeddedBlock> {
300        None
301    }
302
303    fn container_body<'a>(&self, _node: &'a Node<'a>) -> Option<Node<'a>> {
304        None
305    }
306
307    fn body_has_docstring(&self, _body: &Node, _content: &str) -> bool {
308        false
309    }
310
311    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
312        node.child_by_field_name("name")
313            .map(|n| &content[n.byte_range()])
314    }
315
316    fn file_path_to_module_name(&self, path: &Path) -> Option<String> {
317        let ext = path.extension()?.to_str()?;
318        if ext != "erl" && ext != "hrl" {
319            return None;
320        }
321        let stem = path.file_stem()?.to_str()?;
322        Some(stem.to_string())
323    }
324
325    fn module_name_to_paths(&self, module: &str) -> Vec<String> {
326        vec![format!("src/{}.erl", module), format!("{}.erl", module)]
327    }
328
329    fn lang_key(&self) -> &'static str {
330        "erlang"
331    }
332
333    fn is_stdlib_import(&self, import_name: &str, _project_root: &Path) -> bool {
334        // Erlang OTP modules
335        matches!(
336            import_name,
337            "lists"
338                | "maps"
339                | "io"
340                | "file"
341                | "gen_server"
342                | "gen_statem"
343                | "supervisor"
344                | "application"
345                | "ets"
346                | "dets"
347                | "mnesia"
348                | "string"
349                | "binary"
350                | "proplists"
351                | "dict"
352                | "queue"
353                | "sets"
354                | "erlang"
355                | "kernel"
356                | "stdlib"
357                | "crypto"
358                | "ssl"
359                | "inets"
360                | "cowboy"
361                | "ranch"
362                | "logger"
363        )
364    }
365
366    fn find_stdlib(&self, _project_root: &Path) -> Option<PathBuf> {
367        None
368    }
369
370    fn resolve_local_import(
371        &self,
372        import: &str,
373        _current_file: &Path,
374        project_root: &Path,
375    ) -> Option<PathBuf> {
376        let paths = [
377            format!("src/{}.erl", import),
378            format!("include/{}.hrl", import),
379            format!("{}.erl", import),
380        ];
381
382        for p in &paths {
383            let full = project_root.join(p);
384            if full.is_file() {
385                return Some(full);
386            }
387        }
388
389        None
390    }
391
392    fn resolve_external_import(
393        &self,
394        _import_name: &str,
395        _project_root: &Path,
396    ) -> Option<ResolvedPackage> {
397        // Hex/rebar3 package resolution would go here
398        None
399    }
400
401    fn get_version(&self, _project_root: &Path) -> Option<String> {
402        // Check rebar.config or .app.src for version
403        // Would need glob to find *.app.src files
404        None
405    }
406
407    fn find_package_cache(&self, project_root: &Path) -> Option<PathBuf> {
408        let deps = project_root.join("_build/default/lib");
409        if deps.is_dir() {
410            return Some(deps);
411        }
412        let deps = project_root.join("deps");
413        if deps.is_dir() {
414            return Some(deps);
415        }
416        None
417    }
418
419    fn indexable_extensions(&self) -> &'static [&'static str] {
420        &["erl"]
421    }
422    fn package_sources(&self, _project_root: &Path) -> Vec<crate::PackageSource> {
423        Vec::new()
424    }
425
426    fn should_skip_package_entry(&self, name: &str, is_dir: bool) -> bool {
427        use crate::traits::{has_extension, skip_dotfiles};
428        if skip_dotfiles(name) {
429            return true;
430        }
431        if is_dir && (name == "_build" || name == "deps" || name == ".rebar3") {
432            return true;
433        }
434        !is_dir && !has_extension(name, self.indexable_extensions())
435    }
436
437    fn discover_packages(&self, _source: &crate::PackageSource) -> Vec<(String, PathBuf)> {
438        Vec::new()
439    }
440
441    fn package_module_name(&self, entry_name: &str) -> String {
442        entry_name
443            .strip_suffix(".erl")
444            .or_else(|| entry_name.strip_suffix(".hrl"))
445            .unwrap_or(entry_name)
446            .to_string()
447    }
448
449    fn find_package_entry(&self, path: &Path) -> Option<PathBuf> {
450        if path.is_file() {
451            return Some(path.to_path_buf());
452        }
453        // Look for src/<name>.erl
454        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
455            let src = path.join("src").join(format!("{}.erl", name));
456            if src.is_file() {
457                return Some(src);
458            }
459        }
460        None
461    }
462}
463
464#[cfg(test)]
465mod tests {
466    use super::*;
467    use crate::validate_unused_kinds_audit;
468
469    #[test]
470    fn unused_node_kinds_audit() {
471        #[rustfmt::skip]
472        let documented_unused: &[&str] = &[
473            "ann_type", "b_generator", "binary_comprehension", "bit_type_list",
474            "bit_type_unit", "block_expr", "catch_expr", "clause_body",
475            "cond_match_expr", "deprecated_module", "export_attribute",
476            "export_type_attribute", "field_type", "fun_type", "fun_type_sig",
477            "generator", "guard_clause", "import_attribute", "list_comprehension",
478            "map_comprehension", "map_generator", "match_expr", "module",
479            "pp_elif", "pp_else", "pp_endif", "pp_if", "pp_ifdef", "pp_ifndef",
480            "range_type", "remote_module", "replacement_cr_clauses",
481            "replacement_function_clauses", "ssr_definition", "try_after",
482            "try_class", "try_stack", "type_guards", "type_name", "type_sig",
483        ];
484        validate_unused_kinds_audit(&Erlang, documented_unused)
485            .expect("Erlang unused node kinds audit failed");
486    }
487}