Skip to main content

normalize_languages/
rescript.rs

1//! ReScript language support.
2
3use crate::traits::{ImportSpec, ModuleId, ModuleResolver, Resolution, ResolverConfig};
4use crate::{ContainerBody, Import, Language, LanguageSymbols};
5use std::path::{Path, PathBuf};
6use tree_sitter::Node;
7
8/// ReScript language support.
9pub struct ReScript;
10
11impl Language for ReScript {
12    fn name(&self) -> &'static str {
13        "ReScript"
14    }
15    fn extensions(&self) -> &'static [&'static str] {
16        &["res", "resi"]
17    }
18    fn grammar_name(&self) -> &'static str {
19        "rescript"
20    }
21
22    fn as_symbols(&self) -> Option<&dyn LanguageSymbols> {
23        Some(self)
24    }
25
26    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
27        if node.kind() != "open_statement" {
28            return Vec::new();
29        }
30
31        let text = &content[node.byte_range()];
32        vec![Import {
33            module: text.trim().to_string(),
34            names: Vec::new(),
35            alias: None,
36            is_wildcard: true,
37            is_relative: false,
38            line: node.start_position().row + 1,
39        }]
40    }
41
42    fn format_import(&self, import: &Import, _names: Option<&[&str]>) -> String {
43        // ReScript: open Module
44        format!("open {}", import.module)
45    }
46
47    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
48        let name = symbol.name.as_str();
49        match symbol.kind {
50            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
51            crate::SymbolKind::Module => name == "tests" || name == "test",
52            _ => false,
53        }
54    }
55
56    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
57        node.child_by_field_name("body")
58    }
59
60    fn analyze_container_body(
61        &self,
62        body_node: &Node,
63        content: &str,
64        inner_indent: &str,
65    ) -> Option<ContainerBody> {
66        crate::body::analyze_brace_body(body_node, content, inner_indent)
67    }
68
69    fn module_resolver(&self) -> Option<&dyn ModuleResolver> {
70        static RESOLVER: ReScriptModuleResolver = ReScriptModuleResolver;
71        Some(&RESOLVER)
72    }
73}
74
75impl LanguageSymbols for ReScript {}
76
77// =============================================================================
78// ReScript Module Resolver
79// =============================================================================
80
81/// Module resolver for ReScript (BuckleScript/rescript-lang conventions).
82///
83/// ReScript module name = capitalized filename stem.
84/// `open MyModule` → `MyModule.res` or `MyModule.resi` in source directories.
85pub struct ReScriptModuleResolver;
86
87impl ModuleResolver for ReScriptModuleResolver {
88    fn workspace_config(&self, root: &Path) -> ResolverConfig {
89        let mut search_roots: Vec<PathBuf> = Vec::new();
90
91        // Parse bsconfig.json or rescript.json for "sources"
92        for config_name in &["bsconfig.json", "rescript.json"] {
93            let config_path = root.join(config_name);
94            if let Ok(content) = std::fs::read_to_string(&config_path)
95                && let Ok(json) = serde_json::from_str::<serde_json::Value>(&content)
96                && let Some(sources) = json.get("sources")
97            {
98                match sources {
99                    serde_json::Value::String(s) => {
100                        let dir = root.join(s);
101                        if dir.is_dir() {
102                            search_roots.push(dir);
103                        }
104                    }
105                    serde_json::Value::Array(arr) => {
106                        for item in arr {
107                            let dir_str = item
108                                .as_str()
109                                .or_else(|| item.get("dir").and_then(|d| d.as_str()));
110                            if let Some(dir_s) = dir_str {
111                                let dir = root.join(dir_s);
112                                if dir.is_dir() {
113                                    search_roots.push(dir);
114                                }
115                            }
116                        }
117                    }
118                    _ => {}
119                }
120                break;
121            }
122        }
123
124        if search_roots.is_empty() {
125            // Defaults: src/, lib/, root
126            for d in &["src", "lib"] {
127                let dir = root.join(d);
128                if dir.is_dir() {
129                    search_roots.push(dir);
130                }
131            }
132            search_roots.push(root.to_path_buf());
133        }
134
135        ResolverConfig {
136            workspace_root: root.to_path_buf(),
137            path_mappings: Vec::new(),
138            search_roots,
139        }
140    }
141
142    fn module_of_file(&self, _root: &Path, file: &Path, _cfg: &ResolverConfig) -> Vec<ModuleId> {
143        let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
144        if ext != "res" && ext != "resi" {
145            return Vec::new();
146        }
147        if let Some(stem) = file.file_stem().and_then(|s| s.to_str()) {
148            // Capitalize first letter for module name
149            let module_name = {
150                let mut chars = stem.chars();
151                match chars.next() {
152                    None => String::new(),
153                    Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
154                }
155            };
156            return vec![ModuleId {
157                canonical_path: module_name,
158            }];
159        }
160        Vec::new()
161    }
162
163    fn resolve(&self, from_file: &Path, spec: &ImportSpec, cfg: &ResolverConfig) -> Resolution {
164        let ext = from_file.extension().and_then(|e| e.to_str()).unwrap_or("");
165        if ext != "res" && ext != "resi" {
166            return Resolution::NotApplicable;
167        }
168        let raw = &spec.raw;
169        // Strip "open " prefix
170        let module_name = raw.strip_prefix("open ").unwrap_or(raw).trim();
171        if module_name.is_empty() {
172            return Resolution::NotFound;
173        }
174
175        for search_root in &cfg.search_roots {
176            for ext_try in &["res", "resi"] {
177                let candidate = search_root.join(format!("{}.{}", module_name, ext_try));
178                if candidate.exists() {
179                    return Resolution::Resolved(candidate, module_name.to_string());
180                }
181            }
182        }
183        Resolution::NotFound
184    }
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use crate::validate_unused_kinds_audit;
191
192    #[test]
193    fn unused_node_kinds_audit() {
194        #[rustfmt::skip]
195        let documented_unused: &[&str] = &[
196            // Expression nodes
197            "try_expression", "ternary_expression", "while_expression", "for_expression",
198            "call_expression", "pipe_expression", "sequence_expression", "await_expression",
199            "coercion_expression", "lazy_expression", "assert_expression",
200            "parenthesized_expression", "unary_expression", "binary_expression",
201            "subscript_expression", "member_expression", "mutation_expression",
202            "extension_expression",
203            // Type nodes
204            "type_identifier", "type_identifier_path", "unit_type", "generic_type",
205            "function_type", "polyvar_type", "polymorphic_type", "tuple_type",
206            "record_type", "record_type_field", "object_type", "variant_type",
207            "abstract_type", "type_arguments", "type_parameters", "type_constraint",
208            "type_annotation", "type_spread", "constrain_type",
209            "as_aliasing_type", "function_type_parameters",
210            // Module nodes
211            "parenthesized_module_expression", "module_type_constraint", "module_type_annotation",
212            "module_type_of", "constrain_module", "module_identifier", "module_identifier_path",
213            "module_pack", "module_unpack",
214            // Declaration nodes
215            "let_declaration", "exception_declaration", "variant_declaration",
216            "polyvar_declaration", "include_statement",
217            // JSX
218            "jsx_expression", "jsx_identifier", "nested_jsx_identifier",
219            // Pattern matching
220            "exception_pattern", "polyvar_type_pattern",
221            // Identifiers
222            "value_identifier_path", "variant_identifier",
223            "nested_variant_identifier", "polyvar_identifier", "property_identifier",
224            "extension_identifier", "decorator_identifier",
225            // Clauses
226            "else_clause", "else_if_clause",
227            // Other
228            "function", "expression_statement", "formal_parameters",
229            // control flow — not extracted as symbols
230            "if_expression",
231            "block",
232            "switch_expression",
233            "open_statement",
234            "switch_match",
235        ];
236        validate_unused_kinds_audit(&ReScript, documented_unused)
237            .expect("ReScript unused node kinds audit failed");
238    }
239}