Skip to main content

normalize_languages/
elm.rs

1//! Elm language support.
2
3use crate::{Import, Language, LanguageSymbols};
4use tree_sitter::Node;
5
6/// Elm language support.
7pub struct Elm;
8
9impl Language for Elm {
10    fn name(&self) -> &'static str {
11        "Elm"
12    }
13    fn extensions(&self) -> &'static [&'static str] {
14        &["elm"]
15    }
16    fn grammar_name(&self) -> &'static str {
17        "elm"
18    }
19
20    fn as_symbols(&self) -> Option<&dyn LanguageSymbols> {
21        Some(self)
22    }
23
24    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
25        // value_declaration: name is in function_declaration_left > lower_case_identifier
26        if node.kind() == "value_declaration" {
27            let mut cursor = node.walk();
28            for child in node.children(&mut cursor) {
29                if child.kind() == "function_declaration_left" {
30                    let mut inner = child.walk();
31                    for grandchild in child.children(&mut inner) {
32                        if grandchild.kind() == "lower_case_identifier" {
33                            return Some(&content[grandchild.byte_range()]);
34                        }
35                    }
36                }
37            }
38            return None;
39        }
40        // type_alias_declaration, type_declaration: direct upper_case_identifier child
41        let mut cursor = node.walk();
42        for child in node.children(&mut cursor) {
43            if child.kind() == "upper_case_identifier" || child.kind() == "lower_case_identifier" {
44                return Some(&content[child.byte_range()]);
45            }
46        }
47        None
48    }
49
50    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
51        if node.kind() != "import_clause" {
52            return Vec::new();
53        }
54
55        let text = &content[node.byte_range()];
56        let line = node.start_position().row + 1;
57
58        // import Module.Name [as Alias] [exposing (..)]
59        if let Some(rest) = text.strip_prefix("import ") {
60            let parts: Vec<&str> = rest.split_whitespace().collect();
61            if let Some(&module) = parts.first() {
62                let alias = parts
63                    .iter()
64                    .position(|&p| p == "as")
65                    .and_then(|i| parts.get(i + 1))
66                    .map(|s| s.to_string());
67
68                return vec![Import {
69                    module: module.to_string(),
70                    names: Vec::new(),
71                    alias,
72                    is_wildcard: text.contains("exposing (..)"),
73                    is_relative: false,
74                    line,
75                }];
76            }
77        }
78
79        Vec::new()
80    }
81
82    fn format_import(&self, import: &Import, names: Option<&[&str]>) -> String {
83        // Elm: import Module or import Module exposing (a, b, c)
84        let names_to_use: Vec<&str> = names
85            .map(|n| n.to_vec())
86            .unwrap_or_else(|| import.names.iter().map(|s| s.as_str()).collect());
87        if import.is_wildcard {
88            format!("import {} exposing (..)", import.module)
89        } else if names_to_use.is_empty() {
90            format!("import {}", import.module)
91        } else {
92            format!(
93                "import {} exposing ({})",
94                import.module,
95                names_to_use.join(", ")
96            )
97        }
98    }
99
100    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
101        let prev = node.prev_sibling()?;
102
103        if prev.kind() != "block_comment" {
104            return None;
105        }
106
107        let text = &content[prev.byte_range()];
108        // Elm doc comments start with {-| and end with -}
109        let inner = text.strip_prefix("{-|")?;
110        let inner = inner.strip_suffix("-}").unwrap_or(inner).trim().to_string();
111        if inner.is_empty() { None } else { Some(inner) }
112    }
113
114    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
115        let name = symbol.name.as_str();
116        match symbol.kind {
117            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
118            crate::SymbolKind::Module => name == "tests" || name == "test",
119            _ => false,
120        }
121    }
122}
123
124impl LanguageSymbols for Elm {}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::validate_unused_kinds_audit;
130
131    #[test]
132    fn unused_node_kinds_audit() {
133        #[rustfmt::skip]
134        let documented_unused: &[&str] = &[
135            "as_clause", "block_comment", "case", "exposed_operator", "exposed_type",
136            "exposed_union_constructors", "field_accessor_function_expr", "field_type",
137            "function_call_expr", "import", "infix_declaration",
138            "lower_type_name", "module", "nullary_constructor_argument_pattern",
139            "operator", "operator_as_function_expr", "operator_identifier",
140            "record_base_identifier", "record_type", "tuple_type", "type",
141            "type_annotation", "type_expression", "type_ref", "type_variable",
142            "upper_case_qid",
143            // control flow — not extracted as symbols
144            "if_else_expr",
145            "import_clause",
146            "anonymous_function_expr",
147            "module_declaration",
148            "case_of_expr",
149            "case_of_branch",
150        ];
151        validate_unused_kinds_audit(&Elm, documented_unused)
152            .expect("Elm unused node kinds audit failed");
153    }
154}