Skip to main content

normalize_languages/
fsharp.rs

1//! F# language support.
2
3use crate::traits::{ImportSpec, ModuleId, ModuleResolver, Resolution, ResolverConfig};
4use crate::{ContainerBody, Import, Language, LanguageSymbols, Visibility};
5use std::path::Path;
6use tree_sitter::Node;
7
8/// F# language support.
9pub struct FSharp;
10
11impl Language for FSharp {
12    fn name(&self) -> &'static str {
13        "F#"
14    }
15    fn extensions(&self) -> &'static [&'static str] {
16        &["fs", "fsi", "fsx"]
17    }
18    fn grammar_name(&self) -> &'static str {
19        "fsharp"
20    }
21
22    fn as_symbols(&self) -> Option<&dyn LanguageSymbols> {
23        Some(self)
24    }
25
26    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
27        let mut doc_lines: Vec<String> = Vec::new();
28        let mut prev = node.prev_sibling();
29
30        while let Some(sibling) = prev {
31            if sibling.kind() == "line_comment" {
32                let text = &content[sibling.byte_range()];
33                if let Some(rest) = text.strip_prefix("///") {
34                    let line = rest.strip_prefix(' ').unwrap_or(rest);
35                    doc_lines.push(line.to_string());
36                } else {
37                    break;
38                }
39            } else {
40                break;
41            }
42            prev = sibling.prev_sibling();
43        }
44
45        if doc_lines.is_empty() {
46            return None;
47        }
48
49        doc_lines.reverse();
50
51        // Strip XML tags for a cleaner docstring
52        let joined: String = doc_lines
53            .iter()
54            .map(|l| {
55                let l = l.trim();
56                // Strip common XML doc tags like <summary>, </summary>, <param>, etc.
57                if l.starts_with('<') && l.ends_with('>') {
58                    // Pure tag line (e.g. <summary>), skip it
59                    ""
60                } else {
61                    l
62                }
63            })
64            .filter(|l| !l.is_empty())
65            .collect::<Vec<&str>>()
66            .join(" ");
67
68        let trimmed = joined.trim().to_string();
69        if trimmed.is_empty() {
70            None
71        } else {
72            Some(trimmed)
73        }
74    }
75
76    fn build_signature(&self, node: &Node, content: &str) -> String {
77        let text = &content[node.byte_range()];
78        text.lines().next().unwrap_or(text).trim().to_string()
79    }
80
81    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
82        let text = &content[node.byte_range()];
83        let line = node.start_position().row + 1;
84
85        if let Some(rest) = text.strip_prefix("open ") {
86            let module = rest.trim().to_string();
87            return vec![Import {
88                module,
89                names: Vec::new(),
90                alias: None,
91                is_wildcard: true,
92                is_relative: false,
93                line,
94            }];
95        }
96
97        Vec::new()
98    }
99
100    fn format_import(&self, import: &Import, _names: Option<&[&str]>) -> String {
101        // F#: open Namespace
102        format!("open {}", import.module)
103    }
104
105    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
106        let text = &content[node.byte_range()];
107        if text.contains("private ") {
108            Visibility::Private
109        } else if text.contains("internal ") {
110            Visibility::Protected // Using Protected for internal
111        } else {
112            Visibility::Public
113        }
114    }
115
116    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
117        let name = symbol.name.as_str();
118        match symbol.kind {
119            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
120            crate::SymbolKind::Module => name == "tests" || name == "test",
121            _ => false,
122        }
123    }
124
125    fn test_file_globs(&self) -> &'static [&'static str] {
126        &["**/*Test.fs", "**/*Tests.fs"]
127    }
128
129    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
130        node.child_by_field_name("body")
131    }
132
133    fn analyze_container_body(
134        &self,
135        body_node: &Node,
136        content: &str,
137        inner_indent: &str,
138    ) -> Option<ContainerBody> {
139        crate::body::analyze_end_body(body_node, content, inner_indent)
140    }
141
142    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
143        // Try standard field names first
144        if let Some(n) = node
145            .child_by_field_name("name")
146            .or_else(|| node.child_by_field_name("identifier"))
147        {
148            return Some(&content[n.byte_range()]);
149        }
150
151        let kind = node.kind();
152        let mut cursor = node.walk();
153
154        match kind {
155            // function_or_value_defn > function_declaration_left > identifier (first child)
156            "function_or_value_defn" => {
157                for child in node.children(&mut cursor) {
158                    if child.kind() == "function_declaration_left"
159                        || child.kind() == "value_declaration_left"
160                    {
161                        let mut inner = child.walk();
162                        for c in child.children(&mut inner) {
163                            if c.kind() == "identifier" {
164                                return Some(&content[c.byte_range()]);
165                            }
166                        }
167                    }
168                }
169                None
170            }
171            // named_module > long_identifier > identifier (first)
172            "named_module" => {
173                for child in node.children(&mut cursor) {
174                    if child.kind() == "long_identifier" {
175                        let mut inner = child.walk();
176                        for c in child.children(&mut inner) {
177                            if c.kind() == "identifier" {
178                                return Some(&content[c.byte_range()]);
179                            }
180                        }
181                    }
182                }
183                None
184            }
185            // type_definition > *_type_defn > type_name > identifier
186            "type_definition" => {
187                for child in node.children(&mut cursor) {
188                    let ck = child.kind();
189                    if ck.ends_with("_type_defn") || ck == "type_abbrev_defn" {
190                        let mut inner = child.walk();
191                        for c in child.children(&mut inner) {
192                            if c.kind() == "type_name" {
193                                let mut inner2 = c.walk();
194                                for c2 in c.children(&mut inner2) {
195                                    if c2.kind() == "identifier" {
196                                        return Some(&content[c2.byte_range()]);
197                                    }
198                                }
199                            }
200                        }
201                    }
202                }
203                None
204            }
205            // member_defn > method_or_prop_defn > identifier (first)
206            "member_defn" => {
207                for child in node.children(&mut cursor) {
208                    if child.kind() == "method_or_prop_defn" {
209                        let mut inner = child.walk();
210                        for c in child.children(&mut inner) {
211                            if c.kind() == "identifier" {
212                                return Some(&content[c.byte_range()]);
213                            }
214                        }
215                    }
216                }
217                None
218            }
219            _ => None,
220        }
221    }
222
223    fn module_resolver(&self) -> Option<&dyn ModuleResolver> {
224        static RESOLVER: FSharpModuleResolver = FSharpModuleResolver;
225        Some(&RESOLVER)
226    }
227}
228
229impl LanguageSymbols for FSharp {}
230
231// =============================================================================
232// F# Module Resolver
233// =============================================================================
234
235/// Module resolver for F#.
236///
237/// `open MyModule.SubModule` → look for `MyModule/SubModule.fs` relative to workspace root.
238pub struct FSharpModuleResolver;
239
240impl ModuleResolver for FSharpModuleResolver {
241    fn workspace_config(&self, root: &Path) -> ResolverConfig {
242        ResolverConfig {
243            workspace_root: root.to_path_buf(),
244            path_mappings: Vec::new(),
245            search_roots: vec![root.to_path_buf()],
246        }
247    }
248
249    fn module_of_file(&self, root: &Path, file: &Path, _cfg: &ResolverConfig) -> Vec<ModuleId> {
250        let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
251        if ext != "fs" && ext != "fsi" && ext != "fsx" {
252            return Vec::new();
253        }
254        if let Ok(rel) = file.strip_prefix(root) {
255            let rel_str = rel
256                .to_str()
257                .unwrap_or("")
258                .trim_end_matches(".fsx")
259                .trim_end_matches(".fsi")
260                .trim_end_matches(".fs")
261                .replace(['/', '\\'], ".");
262            if !rel_str.is_empty() {
263                return vec![ModuleId {
264                    canonical_path: rel_str,
265                }];
266            }
267        }
268        Vec::new()
269    }
270
271    fn resolve(&self, from_file: &Path, spec: &ImportSpec, cfg: &ResolverConfig) -> Resolution {
272        let ext = from_file.extension().and_then(|e| e.to_str()).unwrap_or("");
273        if ext != "fs" && ext != "fsi" && ext != "fsx" {
274            return Resolution::NotApplicable;
275        }
276        // Strip "open " prefix if present
277        let raw = spec.raw.strip_prefix("open ").unwrap_or(&spec.raw).trim();
278        let exported_name = raw.rsplit('.').next().unwrap_or(raw).to_string();
279        let path_part = raw.replace('.', "/");
280
281        for ext_try in &["fs", "fsi", "fsx"] {
282            let candidate = cfg
283                .workspace_root
284                .join(format!("{}.{}", path_part, ext_try));
285            if candidate.exists() {
286                return Resolution::Resolved(candidate, exported_name.clone());
287            }
288        }
289        // Also try last component in same directory as from_file
290        if let Some(parent) = from_file.parent() {
291            let last = raw.rsplit('.').next().unwrap_or(raw);
292            for ext_try in &["fs", "fsi"] {
293                let candidate = parent.join(format!("{}.{}", last, ext_try));
294                if candidate.exists() {
295                    return Resolution::Resolved(candidate, exported_name.clone());
296                }
297            }
298        }
299        Resolution::NotFound
300    }
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306    use crate::validate_unused_kinds_audit;
307
308    #[test]
309    fn unused_node_kinds_audit() {
310        #[rustfmt::skip]
311        let documented_unused: &[&str] = &[
312            "access_modifier", "anon_record_expression", "anon_record_type",
313            "anon_type_defn", "array_expression", "atomic_type", "begin_end_expression",
314            "block_comment", "block_comment_content", "brace_expression",
315            "ce_expression", "class_as_reference", "class_inherits_decl",
316            "compound_type", "constrained_type", "declaration_expression",
317            "delegate_type_defn", "do_expression", "dot_expression", "elif_expression",
318            "enum_type_case", "enum_type_cases", "enum_type_defn",
319            "exception_definition", "flexible_type", "format_string",
320            "format_string_eval", "format_triple_quoted_string", "fun_expression", "function_expression", "function_type",
321            "generic_type", "identifier_pattern", "index_expression", "interface_implementation",
322            "interface_type_defn", "list_expression", "list_type", "literal_expression",
323            "long_identifier_or_op",
324            "module_abbrev", "module_defn", "mutate_expression", "object_expression",
325            "op_identifier", "paren_expression", "paren_type", "postfix_type",
326            "prefixed_expression", "preproc_else", "preproc_if", "range_expression",
327            "sequential_expression", "short_comp_expression", "simple_type",
328            "static_type", "trait_member_constraint", "tuple_expression",
329            "type_abbrev_defn", "type_argument", "type_argument_constraints",
330            "type_argument_defn", "type_arguments", "type_attribute", "type_attributes",
331            "type_check_pattern", "type_extension", "type_extension_elements", "typed_expression", "typed_pattern", "typecast_expression",
332            "types", "union_type_case", "union_type_cases", "union_type_field",
333            "union_type_fields", "value_declaration", "value_declaration_left",
334            "with_field_expression",
335            // covered by tags.scm
336            "union_type_defn",
337            "for_expression",
338            "application_expression",
339            "import_decl",
340            "while_expression",
341            "match_expression",
342            "record_type_defn",
343            "infix_expression",
344            "if_expression",
345            "try_expression",
346        ];
347        validate_unused_kinds_audit(&FSharp, documented_unused)
348            .expect("F# unused node kinds audit failed");
349    }
350}