normalize_languages/
elm.rs1use std::path::{Path, PathBuf};
4
5use crate::{
6 Import, ImportSpec, Language, LanguageSymbols, ModuleId, ModuleResolver, Resolution,
7 ResolverConfig,
8};
9use tree_sitter::Node;
10
11pub 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 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 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 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 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 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
136pub 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 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 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 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 "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}