Skip to main content

normalize_languages/
elm.rs

1//! Elm language support.
2
3use std::path::{Path, PathBuf};
4
5use crate::{
6    Import, ImportSpec, Language, LanguageSymbols, ModuleId, ModuleResolver, Resolution,
7    ResolverConfig,
8};
9use tree_sitter::Node;
10
11/// Elm language support.
12pub struct Elm;
13
14impl Language for Elm {
15    fn name(&self) -> &'static str {
16        "Elm"
17    }
18    fn extensions(&self) -> &'static [&'static str] {
19        &["elm"]
20    }
21    fn grammar_name(&self) -> &'static str {
22        "elm"
23    }
24
25    fn as_symbols(&self) -> Option<&dyn LanguageSymbols> {
26        Some(self)
27    }
28
29    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
30        // value_declaration: name is in function_declaration_left > lower_case_identifier
31        if node.kind() == "value_declaration" {
32            let mut cursor = node.walk();
33            for child in node.children(&mut cursor) {
34                if child.kind() == "function_declaration_left" {
35                    let mut inner = child.walk();
36                    for grandchild in child.children(&mut inner) {
37                        if grandchild.kind() == "lower_case_identifier" {
38                            return Some(&content[grandchild.byte_range()]);
39                        }
40                    }
41                }
42            }
43            return None;
44        }
45        // type_alias_declaration, type_declaration: direct upper_case_identifier child
46        let mut cursor = node.walk();
47        for child in node.children(&mut cursor) {
48            if child.kind() == "upper_case_identifier" || child.kind() == "lower_case_identifier" {
49                return Some(&content[child.byte_range()]);
50            }
51        }
52        None
53    }
54
55    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
56        if node.kind() != "import_clause" {
57            return Vec::new();
58        }
59
60        let text = &content[node.byte_range()];
61        let line = node.start_position().row + 1;
62
63        // import Module.Name [as Alias] [exposing (..)]
64        if let Some(rest) = text.strip_prefix("import ") {
65            let parts: Vec<&str> = rest.split_whitespace().collect();
66            if let Some(&module) = parts.first() {
67                let alias = parts
68                    .iter()
69                    .position(|&p| p == "as")
70                    .and_then(|i| parts.get(i + 1))
71                    .map(|s| s.to_string());
72
73                return vec![Import {
74                    module: module.to_string(),
75                    names: Vec::new(),
76                    alias,
77                    is_wildcard: text.contains("exposing (..)"),
78                    is_relative: false,
79                    line,
80                }];
81            }
82        }
83
84        Vec::new()
85    }
86
87    fn format_import(&self, import: &Import, names: Option<&[&str]>) -> String {
88        // Elm: import Module or import Module exposing (a, b, c)
89        let names_to_use: Vec<&str> = names
90            .map(|n| n.to_vec())
91            .unwrap_or_else(|| import.names.iter().map(|s| s.as_str()).collect());
92        if import.is_wildcard {
93            format!("import {} exposing (..)", import.module)
94        } else if names_to_use.is_empty() {
95            format!("import {}", import.module)
96        } else {
97            format!(
98                "import {} exposing ({})",
99                import.module,
100                names_to_use.join(", ")
101            )
102        }
103    }
104
105    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
106        let prev = node.prev_sibling()?;
107
108        if prev.kind() != "block_comment" {
109            return None;
110        }
111
112        let text = &content[prev.byte_range()];
113        // Elm doc comments start with {-| and end with -}
114        let inner = text.strip_prefix("{-|")?;
115        let inner = inner.strip_suffix("-}").unwrap_or(inner).trim().to_string();
116        if inner.is_empty() { None } else { Some(inner) }
117    }
118
119    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
120        let name = symbol.name.as_str();
121        match symbol.kind {
122            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
123            crate::SymbolKind::Module => name == "tests" || name == "test",
124            _ => false,
125        }
126    }
127
128    fn module_resolver(&self) -> Option<&dyn ModuleResolver> {
129        static RESOLVER: ElmModuleResolver = ElmModuleResolver;
130        Some(&RESOLVER)
131    }
132}
133
134impl LanguageSymbols for Elm {}
135
136// =============================================================================
137// Elm Module Resolver
138// =============================================================================
139
140/// Module resolver for Elm.
141///
142/// Reads `elm.json` to find source directories. Module names map directly
143/// to file paths: `Html.Attributes` → `Html/Attributes.elm` under a source root.
144pub struct ElmModuleResolver;
145
146impl ModuleResolver for ElmModuleResolver {
147    fn workspace_config(&self, root: &Path) -> ResolverConfig {
148        let mut search_roots: Vec<PathBuf> = Vec::new();
149
150        let elm_json = root.join("elm.json");
151        if let Ok(content) = std::fs::read_to_string(&elm_json)
152            && let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&content)
153            && let Some(dirs) = parsed.get("source-directories").and_then(|v| v.as_array())
154        {
155            for dir in dirs {
156                if let Some(s) = dir.as_str() {
157                    search_roots.push(root.join(s));
158                }
159            }
160        }
161
162        // Default to src/ if elm.json not found or has no source-directories
163        if search_roots.is_empty() {
164            search_roots.push(root.join("src"));
165        }
166
167        ResolverConfig {
168            workspace_root: root.to_path_buf(),
169            path_mappings: Vec::new(),
170            search_roots,
171        }
172    }
173
174    fn module_of_file(&self, _root: &Path, file: &Path, cfg: &ResolverConfig) -> Vec<ModuleId> {
175        let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
176        if ext != "elm" {
177            return Vec::new();
178        }
179
180        for root in &cfg.search_roots {
181            if let Ok(rel) = file.strip_prefix(root) {
182                let rel_str = rel.to_string_lossy();
183                // Strip .elm and replace / with .
184                let base = rel_str.strip_suffix(".elm").unwrap_or(&rel_str);
185                let canonical = if cfg!(windows) {
186                    base.replace('\\', ".")
187                } else {
188                    base.replace('/', ".")
189                };
190                if !canonical.is_empty() {
191                    return vec![ModuleId {
192                        canonical_path: canonical,
193                    }];
194                }
195            }
196        }
197
198        Vec::new()
199    }
200
201    fn resolve(&self, from_file: &Path, spec: &ImportSpec, cfg: &ResolverConfig) -> Resolution {
202        let ext = from_file.extension().and_then(|e| e.to_str()).unwrap_or("");
203        if ext != "elm" {
204            return Resolution::NotApplicable;
205        }
206
207        // `Html.Attributes` → `Html/Attributes.elm`
208        let file_path = spec.raw.replace('.', "/") + ".elm";
209
210        for root in &cfg.search_roots {
211            let candidate = root.join(&file_path);
212            if candidate.exists() {
213                return Resolution::Resolved(candidate, String::new());
214            }
215        }
216
217        Resolution::NotFound
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::validate_unused_kinds_audit;
225
226    #[test]
227    fn unused_node_kinds_audit() {
228        #[rustfmt::skip]
229        let documented_unused: &[&str] = &[
230            "as_clause", "block_comment", "case", "exposed_operator", "exposed_type",
231            "exposed_union_constructors", "field_accessor_function_expr", "field_type",
232            "function_call_expr", "import", "infix_declaration",
233            "lower_type_name", "module", "nullary_constructor_argument_pattern",
234            "operator", "operator_as_function_expr", "operator_identifier",
235            "record_base_identifier", "record_type", "tuple_type", "type",
236            "type_annotation", "type_expression", "type_ref", "type_variable",
237            "upper_case_qid",
238            // control flow — not extracted as symbols
239            "if_else_expr",
240            "import_clause",
241            "anonymous_function_expr",
242            "module_declaration",
243            "case_of_expr",
244            "case_of_branch",
245        ];
246        validate_unused_kinds_audit(&Elm, documented_unused)
247            .expect("Elm unused node kinds audit failed");
248    }
249}