Skip to main content

normalize_languages/
fsharp.rs

1//! F# language support.
2
3use crate::external_packages::ResolvedPackage;
4use crate::{Export, Import, Language, Symbol, SymbolKind, Visibility, VisibilityMechanism};
5use std::path::{Path, PathBuf};
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 has_symbols(&self) -> bool {
23        true
24    }
25
26    fn container_kinds(&self) -> &'static [&'static str] {
27        &["module_defn", "type_definition"]
28    }
29
30    fn function_kinds(&self) -> &'static [&'static str] {
31        &["function_or_value_defn", "member_defn"]
32    }
33
34    fn type_kinds(&self) -> &'static [&'static str] {
35        &["type_definition", "record_type_defn", "union_type_defn"]
36    }
37
38    fn import_kinds(&self) -> &'static [&'static str] {
39        &["import_decl"]
40    }
41
42    fn public_symbol_kinds(&self) -> &'static [&'static str] {
43        &["function_or_value_defn", "type_definition", "module_defn"]
44    }
45
46    fn visibility_mechanism(&self) -> VisibilityMechanism {
47        VisibilityMechanism::AccessModifier // public, private, internal
48    }
49
50    fn extract_public_symbols(&self, node: &Node, content: &str) -> Vec<Export> {
51        if !self.is_public(node, content) {
52            return Vec::new();
53        }
54
55        let name = match self.node_name(node, content) {
56            Some(n) => n.to_string(),
57            None => return Vec::new(),
58        };
59
60        let kind = match node.kind() {
61            "function_or_value_defn" => SymbolKind::Function,
62            "member_defn" => SymbolKind::Method,
63            "type_definition" | "record_type_defn" => SymbolKind::Struct,
64            "union_type_defn" => SymbolKind::Enum,
65            "module_defn" => SymbolKind::Module,
66            _ => return Vec::new(),
67        };
68
69        vec![Export {
70            name,
71            kind,
72            line: node.start_position().row + 1,
73        }]
74    }
75
76    fn scope_creating_kinds(&self) -> &'static [&'static str] {
77        &[
78            "for_expression",
79            "while_expression",
80            "try_expression",
81            "match_expression",
82        ]
83    }
84
85    fn control_flow_kinds(&self) -> &'static [&'static str] {
86        &[
87            "if_expression",
88            "match_expression",
89            "for_expression",
90            "while_expression",
91            "try_expression",
92            "application_expression",
93        ]
94    }
95
96    fn complexity_nodes(&self) -> &'static [&'static str] {
97        &[
98            "if_expression",
99            "rule",
100            "for_expression",
101            "while_expression",
102            "try_expression",
103            "infix_expression",
104        ]
105    }
106
107    fn nesting_nodes(&self) -> &'static [&'static str] {
108        &[
109            "if_expression",
110            "match_expression",
111            "for_expression",
112            "while_expression",
113            "try_expression",
114            "function_or_value_defn",
115            "module_defn",
116        ]
117    }
118
119    fn signature_suffix(&self) -> &'static str {
120        ""
121    }
122
123    fn extract_function(&self, node: &Node, content: &str, _in_container: bool) -> Option<Symbol> {
124        let name = self.node_name(node, content)?;
125
126        // Extract first line as signature
127        let text = &content[node.byte_range()];
128        let first_line = text.lines().next().unwrap_or(text);
129
130        let is_member = node.kind() == "member_defn";
131        let kind = if is_member {
132            SymbolKind::Method
133        } else {
134            SymbolKind::Function
135        };
136
137        Some(Symbol {
138            name: name.to_string(),
139            kind,
140            signature: first_line.trim().to_string(),
141            docstring: self.extract_docstring(node, content),
142            attributes: Vec::new(),
143            start_line: node.start_position().row + 1,
144            end_line: node.end_position().row + 1,
145            visibility: self.get_visibility(node, content),
146            children: Vec::new(),
147            is_interface_impl: false,
148            implements: Vec::new(),
149        })
150    }
151
152    fn extract_container(&self, node: &Node, content: &str) -> Option<Symbol> {
153        let name = self.node_name(node, content)?;
154        let (kind, keyword) = match node.kind() {
155            "union_type_defn" => (SymbolKind::Enum, "type"),
156            "record_type_defn" => (SymbolKind::Struct, "type"),
157            "module_defn" => (SymbolKind::Module, "module"),
158            _ => (SymbolKind::Struct, "type"),
159        };
160
161        Some(Symbol {
162            name: name.to_string(),
163            kind,
164            signature: format!("{} {}", keyword, name),
165            docstring: self.extract_docstring(node, content),
166            attributes: Vec::new(),
167            start_line: node.start_position().row + 1,
168            end_line: node.end_position().row + 1,
169            visibility: self.get_visibility(node, content),
170            children: Vec::new(),
171            is_interface_impl: false,
172            implements: Vec::new(),
173        })
174    }
175
176    fn extract_type(&self, node: &Node, content: &str) -> Option<Symbol> {
177        self.extract_container(node, content)
178    }
179
180    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
181        // F# uses /// for XML doc comments
182        let mut prev = node.prev_sibling();
183        let mut doc_lines = Vec::new();
184
185        while let Some(sibling) = prev {
186            let text = &content[sibling.byte_range()];
187            if text.starts_with("///") {
188                let line = text.strip_prefix("///").unwrap_or(text).trim();
189                // Strip XML tags
190                let clean = line
191                    .replace("<summary>", "")
192                    .replace("</summary>", "")
193                    .replace("<param name=\"", "")
194                    .replace("</param>", "")
195                    .replace("<returns>", "")
196                    .replace("</returns>", "")
197                    .trim()
198                    .to_string();
199                if !clean.is_empty() {
200                    doc_lines.push(clean);
201                }
202                prev = sibling.prev_sibling();
203            } else {
204                break;
205            }
206        }
207
208        if doc_lines.is_empty() {
209            return None;
210        }
211
212        doc_lines.reverse();
213        Some(doc_lines.join(" "))
214    }
215
216    fn extract_attributes(&self, _node: &Node, _content: &str) -> Vec<String> {
217        Vec::new()
218    }
219
220    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
221        let text = &content[node.byte_range()];
222        let line = node.start_position().row + 1;
223
224        if let Some(rest) = text.strip_prefix("open ") {
225            let module = rest.trim().to_string();
226            return vec![Import {
227                module,
228                names: Vec::new(),
229                alias: None,
230                is_wildcard: true,
231                is_relative: false,
232                line,
233            }];
234        }
235
236        Vec::new()
237    }
238
239    fn format_import(&self, import: &Import, _names: Option<&[&str]>) -> String {
240        // F#: open Namespace
241        format!("open {}", import.module)
242    }
243
244    fn is_public(&self, node: &Node, content: &str) -> bool {
245        let text = &content[node.byte_range()];
246        // F# defaults to public in modules
247        !text.contains("private ") && !text.contains("internal ")
248    }
249
250    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
251        let text = &content[node.byte_range()];
252        if text.contains("private ") {
253            Visibility::Private
254        } else if text.contains("internal ") {
255            Visibility::Protected // Using Protected for internal
256        } else {
257            Visibility::Public
258        }
259    }
260
261    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
262        let name = symbol.name.as_str();
263        match symbol.kind {
264            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
265            crate::SymbolKind::Module => name == "tests" || name == "test",
266            _ => false,
267        }
268    }
269
270    fn embedded_content(&self, _node: &Node, _content: &str) -> Option<crate::EmbeddedBlock> {
271        None
272    }
273
274    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
275        node.child_by_field_name("body")
276    }
277
278    fn body_has_docstring(&self, _body: &Node, _content: &str) -> bool {
279        false
280    }
281
282    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
283        node.child_by_field_name("name")
284            .or_else(|| node.child_by_field_name("identifier"))
285            .map(|n| &content[n.byte_range()])
286    }
287
288    fn file_path_to_module_name(&self, path: &Path) -> Option<String> {
289        let ext = path.extension()?.to_str()?;
290        if ext != "fs" && ext != "fsi" && ext != "fsx" {
291            return None;
292        }
293        let stem = path.file_stem()?.to_str()?;
294        // F# typically uses PascalCase module names
295        Some(stem.to_string())
296    }
297
298    fn module_name_to_paths(&self, module: &str) -> Vec<String> {
299        let parts: Vec<&str> = module.split('.').collect();
300        let file_name = parts.last().unwrap_or(&module);
301        vec![format!("{}.fs", file_name), format!("src/{}.fs", file_name)]
302    }
303
304    fn lang_key(&self) -> &'static str {
305        "fsharp"
306    }
307
308    fn is_stdlib_import(&self, import_name: &str, _project_root: &Path) -> bool {
309        // .NET BCL namespaces
310        import_name.starts_with("System")
311            || import_name.starts_with("Microsoft.FSharp")
312            || import_name.starts_with("FSharp.")
313    }
314
315    fn find_stdlib(&self, _project_root: &Path) -> Option<PathBuf> {
316        None
317    }
318
319    fn resolve_local_import(
320        &self,
321        import: &str,
322        _current_file: &Path,
323        project_root: &Path,
324    ) -> Option<PathBuf> {
325        let parts: Vec<&str> = import.split('.').collect();
326        let file_name = parts.last()?;
327
328        let paths = [format!("{}.fs", file_name), format!("src/{}.fs", file_name)];
329
330        for p in &paths {
331            let full = project_root.join(p);
332            if full.is_file() {
333                return Some(full);
334            }
335        }
336
337        None
338    }
339
340    fn resolve_external_import(
341        &self,
342        _import_name: &str,
343        _project_root: &Path,
344    ) -> Option<ResolvedPackage> {
345        // NuGet package resolution (similar to C#)
346        None
347    }
348
349    fn get_version(&self, project_root: &Path) -> Option<String> {
350        // Check .fsproj for version
351        for entry in std::fs::read_dir(project_root).ok()? {
352            let entry = entry.ok()?;
353            let path = entry.path();
354            if path.extension().map_or(false, |e| e == "fsproj")
355                && let Ok(content) = std::fs::read_to_string(&path)
356                && let Some(start) = content.find("<Version>")
357            {
358                let rest = &content[start + 9..];
359                if let Some(end) = rest.find("</Version>") {
360                    return Some(rest[..end].to_string());
361                }
362            }
363        }
364        None
365    }
366
367    fn find_package_cache(&self, _project_root: &Path) -> Option<PathBuf> {
368        // NuGet cache
369        if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
370            let cache = PathBuf::from(home).join(".nuget/packages");
371            if cache.is_dir() {
372                return Some(cache);
373            }
374        }
375        None
376    }
377
378    fn indexable_extensions(&self) -> &'static [&'static str] {
379        &["fs"]
380    }
381    fn package_sources(&self, _project_root: &Path) -> Vec<crate::PackageSource> {
382        Vec::new()
383    }
384
385    fn should_skip_package_entry(&self, name: &str, is_dir: bool) -> bool {
386        use crate::traits::{has_extension, skip_dotfiles};
387        if skip_dotfiles(name) {
388            return true;
389        }
390        if is_dir && (name == "bin" || name == "obj" || name == "packages") {
391            return true;
392        }
393        !is_dir && !has_extension(name, self.indexable_extensions())
394    }
395
396    fn discover_packages(&self, _source: &crate::PackageSource) -> Vec<(String, PathBuf)> {
397        Vec::new()
398    }
399
400    fn package_module_name(&self, entry_name: &str) -> String {
401        entry_name
402            .strip_suffix(".fs")
403            .or_else(|| entry_name.strip_suffix(".fsi"))
404            .unwrap_or(entry_name)
405            .to_string()
406    }
407
408    fn find_package_entry(&self, path: &Path) -> Option<PathBuf> {
409        if path.is_file() {
410            return Some(path.to_path_buf());
411        }
412        None
413    }
414}
415
416#[cfg(test)]
417mod tests {
418    use super::*;
419    use crate::validate_unused_kinds_audit;
420
421    #[test]
422    fn unused_node_kinds_audit() {
423        #[rustfmt::skip]
424        let documented_unused: &[&str] = &[
425            "access_modifier", "anon_record_expression", "anon_record_type",
426            "anon_type_defn", "array_expression", "atomic_type", "begin_end_expression",
427            "block_comment", "block_comment_content", "brace_expression",
428            "ce_expression", "class_as_reference", "class_inherits_decl",
429            "compound_type", "constrained_type", "declaration_expression",
430            "delegate_type_defn", "do_expression", "dot_expression", "elif_expression",
431            "enum_type_case", "enum_type_cases", "enum_type_defn",
432            "exception_definition", "flexible_type", "format_string",
433            "format_string_eval", "format_triple_quoted_string", "fun_expression",
434            "function_declaration_left", "function_expression", "function_type",
435            "generic_type", "identifier", "identifier_pattern", "index_expression", "interface_implementation",
436            "interface_type_defn", "list_expression", "list_type", "literal_expression",
437            "long_identifier", "long_identifier_or_op", "method_or_prop_defn",
438            "module_abbrev", "mutate_expression", "named_module", "object_expression",
439            "op_identifier", "paren_expression", "paren_type", "postfix_type",
440            "prefixed_expression", "preproc_else", "preproc_if", "range_expression",
441            "sequential_expression", "short_comp_expression", "simple_type",
442            "static_type", "trait_member_constraint", "tuple_expression",
443            "type_abbrev_defn", "type_argument", "type_argument_constraints",
444            "type_argument_defn", "type_arguments", "type_attribute", "type_attributes",
445            "type_check_pattern", "type_extension", "type_extension_elements",
446            "type_name", "typed_expression", "typed_pattern", "typecast_expression",
447            "types", "union_type_case", "union_type_cases", "union_type_field",
448            "union_type_fields", "value_declaration", "value_declaration_left",
449            "with_field_expression",
450        ];
451        validate_unused_kinds_audit(&FSharp, documented_unused)
452            .expect("F# unused node kinds audit failed");
453    }
454}