normalize-languages 0.3.2

Tree-sitter language support and dynamic grammar loading
Documentation
//! F# language support.

use crate::traits::{ImportSpec, ModuleId, ModuleResolver, Resolution, ResolverConfig};
use crate::{ContainerBody, Import, Language, LanguageSymbols, Visibility};
use std::path::Path;
use tree_sitter::Node;

/// F# language support.
pub struct FSharp;

impl Language for FSharp {
    fn name(&self) -> &'static str {
        "F#"
    }
    fn extensions(&self) -> &'static [&'static str] {
        &["fs", "fsi", "fsx"]
    }
    fn grammar_name(&self) -> &'static str {
        "fsharp"
    }

    fn as_symbols(&self) -> Option<&dyn LanguageSymbols> {
        Some(self)
    }

    fn extract_docstring(&self, node: &Node, content: &str) -> Option<String> {
        let mut doc_lines: Vec<String> = Vec::new();
        let mut prev = node.prev_sibling();

        while let Some(sibling) = prev {
            if sibling.kind() == "line_comment" {
                let text = &content[sibling.byte_range()];
                if let Some(rest) = text.strip_prefix("///") {
                    let line = rest.strip_prefix(' ').unwrap_or(rest);
                    doc_lines.push(line.to_string());
                } else {
                    break;
                }
            } else {
                break;
            }
            prev = sibling.prev_sibling();
        }

        if doc_lines.is_empty() {
            return None;
        }

        doc_lines.reverse();

        // Strip XML tags for a cleaner docstring
        let joined: String = doc_lines
            .iter()
            .map(|l| {
                let l = l.trim();
                // Strip common XML doc tags like <summary>, </summary>, <param>, etc.
                if l.starts_with('<') && l.ends_with('>') {
                    // Pure tag line (e.g. <summary>), skip it
                    ""
                } else {
                    l
                }
            })
            .filter(|l| !l.is_empty())
            .collect::<Vec<&str>>()
            .join(" ");

        let trimmed = joined.trim().to_string();
        if trimmed.is_empty() {
            None
        } else {
            Some(trimmed)
        }
    }

    fn build_signature(&self, node: &Node, content: &str) -> String {
        let text = &content[node.byte_range()];
        text.lines().next().unwrap_or(text).trim().to_string()
    }

    fn extract_imports(&self, node: &Node, content: &str) -> Vec<Import> {
        let text = &content[node.byte_range()];
        let line = node.start_position().row + 1;

        if let Some(rest) = text.strip_prefix("open ") {
            let module = rest.trim().to_string();
            return vec![Import {
                module,
                names: Vec::new(),
                alias: None,
                is_wildcard: true,
                is_relative: false,
                line,
            }];
        }

        Vec::new()
    }

    fn format_import(&self, import: &Import, _names: Option<&[&str]>) -> String {
        // F#: open Namespace
        format!("open {}", import.module)
    }

    fn get_visibility(&self, node: &Node, content: &str) -> Visibility {
        let text = &content[node.byte_range()];
        if text.contains("private ") {
            Visibility::Private
        } else if text.contains("internal ") {
            Visibility::Protected // Using Protected for internal
        } else {
            Visibility::Public
        }
    }

    fn is_test_symbol(&self, symbol: &crate::Symbol) -> bool {
        let name = symbol.name.as_str();
        match symbol.kind {
            crate::SymbolKind::Function | crate::SymbolKind::Method => name.starts_with("test_"),
            crate::SymbolKind::Module => name == "tests" || name == "test",
            _ => false,
        }
    }

    fn test_file_globs(&self) -> &'static [&'static str] {
        &["**/*Test.fs", "**/*Tests.fs"]
    }

    fn container_body<'a>(&self, node: &'a Node<'a>) -> Option<Node<'a>> {
        node.child_by_field_name("body")
    }

    fn analyze_container_body(
        &self,
        body_node: &Node,
        content: &str,
        inner_indent: &str,
    ) -> Option<ContainerBody> {
        crate::body::analyze_end_body(body_node, content, inner_indent)
    }

    fn node_name<'a>(&self, node: &Node, content: &'a str) -> Option<&'a str> {
        // Try standard field names first
        if let Some(n) = node
            .child_by_field_name("name")
            .or_else(|| node.child_by_field_name("identifier"))
        {
            return Some(&content[n.byte_range()]);
        }

        let kind = node.kind();
        let mut cursor = node.walk();

        match kind {
            // function_or_value_defn > function_declaration_left > identifier (first child)
            "function_or_value_defn" => {
                for child in node.children(&mut cursor) {
                    if child.kind() == "function_declaration_left"
                        || child.kind() == "value_declaration_left"
                    {
                        let mut inner = child.walk();
                        for c in child.children(&mut inner) {
                            if c.kind() == "identifier" {
                                return Some(&content[c.byte_range()]);
                            }
                        }
                    }
                }
                None
            }
            // named_module > long_identifier > identifier (first)
            "named_module" => {
                for child in node.children(&mut cursor) {
                    if child.kind() == "long_identifier" {
                        let mut inner = child.walk();
                        for c in child.children(&mut inner) {
                            if c.kind() == "identifier" {
                                return Some(&content[c.byte_range()]);
                            }
                        }
                    }
                }
                None
            }
            // type_definition > *_type_defn > type_name > identifier
            "type_definition" => {
                for child in node.children(&mut cursor) {
                    let ck = child.kind();
                    if ck.ends_with("_type_defn") || ck == "type_abbrev_defn" {
                        let mut inner = child.walk();
                        for c in child.children(&mut inner) {
                            if c.kind() == "type_name" {
                                let mut inner2 = c.walk();
                                for c2 in c.children(&mut inner2) {
                                    if c2.kind() == "identifier" {
                                        return Some(&content[c2.byte_range()]);
                                    }
                                }
                            }
                        }
                    }
                }
                None
            }
            // member_defn > method_or_prop_defn > identifier (first)
            "member_defn" => {
                for child in node.children(&mut cursor) {
                    if child.kind() == "method_or_prop_defn" {
                        let mut inner = child.walk();
                        for c in child.children(&mut inner) {
                            if c.kind() == "identifier" {
                                return Some(&content[c.byte_range()]);
                            }
                        }
                    }
                }
                None
            }
            _ => None,
        }
    }

    fn module_resolver(&self) -> Option<&dyn ModuleResolver> {
        static RESOLVER: FSharpModuleResolver = FSharpModuleResolver;
        Some(&RESOLVER)
    }
}

impl LanguageSymbols for FSharp {}

// =============================================================================
// F# Module Resolver
// =============================================================================

/// Module resolver for F#.
///
/// `open MyModule.SubModule` → look for `MyModule/SubModule.fs` relative to workspace root.
pub struct FSharpModuleResolver;

impl ModuleResolver for FSharpModuleResolver {
    fn workspace_config(&self, root: &Path) -> ResolverConfig {
        ResolverConfig {
            workspace_root: root.to_path_buf(),
            path_mappings: Vec::new(),
            search_roots: vec![root.to_path_buf()],
        }
    }

    fn module_of_file(&self, root: &Path, file: &Path, _cfg: &ResolverConfig) -> Vec<ModuleId> {
        let ext = file.extension().and_then(|e| e.to_str()).unwrap_or("");
        if ext != "fs" && ext != "fsi" && ext != "fsx" {
            return Vec::new();
        }
        if let Ok(rel) = file.strip_prefix(root) {
            let rel_str = rel
                .to_str()
                .unwrap_or("")
                .trim_end_matches(".fsx")
                .trim_end_matches(".fsi")
                .trim_end_matches(".fs")
                .replace(['/', '\\'], ".");
            if !rel_str.is_empty() {
                return vec![ModuleId {
                    canonical_path: rel_str,
                }];
            }
        }
        Vec::new()
    }

    fn resolve(&self, from_file: &Path, spec: &ImportSpec, cfg: &ResolverConfig) -> Resolution {
        let ext = from_file.extension().and_then(|e| e.to_str()).unwrap_or("");
        if ext != "fs" && ext != "fsi" && ext != "fsx" {
            return Resolution::NotApplicable;
        }
        // Strip "open " prefix if present
        let raw = spec.raw.strip_prefix("open ").unwrap_or(&spec.raw).trim();
        let exported_name = raw.rsplit('.').next().unwrap_or(raw).to_string();
        let path_part = raw.replace('.', "/");

        for ext_try in &["fs", "fsi", "fsx"] {
            let candidate = cfg
                .workspace_root
                .join(format!("{}.{}", path_part, ext_try));
            if candidate.exists() {
                return Resolution::Resolved(candidate, exported_name.clone());
            }
        }
        // Also try last component in same directory as from_file
        if let Some(parent) = from_file.parent() {
            let last = raw.rsplit('.').next().unwrap_or(raw);
            for ext_try in &["fs", "fsi"] {
                let candidate = parent.join(format!("{}.{}", last, ext_try));
                if candidate.exists() {
                    return Resolution::Resolved(candidate, exported_name.clone());
                }
            }
        }
        Resolution::NotFound
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::validate_unused_kinds_audit;

    #[test]
    fn unused_node_kinds_audit() {
        #[rustfmt::skip]
        let documented_unused: &[&str] = &[
            "access_modifier", "anon_record_expression", "anon_record_type",
            "anon_type_defn", "array_expression", "atomic_type", "begin_end_expression",
            "block_comment", "block_comment_content", "brace_expression",
            "ce_expression", "class_as_reference", "class_inherits_decl",
            "compound_type", "constrained_type", "declaration_expression",
            "delegate_type_defn", "do_expression", "dot_expression", "elif_expression",
            "enum_type_case", "enum_type_cases", "enum_type_defn",
            "exception_definition", "flexible_type", "format_string",
            "format_string_eval", "format_triple_quoted_string", "fun_expression", "function_expression", "function_type",
            "generic_type", "identifier_pattern", "index_expression", "interface_implementation",
            "interface_type_defn", "list_expression", "list_type", "literal_expression",
            "long_identifier_or_op",
            "module_abbrev", "module_defn", "mutate_expression", "object_expression",
            "op_identifier", "paren_expression", "paren_type", "postfix_type",
            "prefixed_expression", "preproc_else", "preproc_if", "range_expression",
            "sequential_expression", "short_comp_expression", "simple_type",
            "static_type", "trait_member_constraint", "tuple_expression",
            "type_abbrev_defn", "type_argument", "type_argument_constraints",
            "type_argument_defn", "type_arguments", "type_attribute", "type_attributes",
            "type_check_pattern", "type_extension", "type_extension_elements", "typed_expression", "typed_pattern", "typecast_expression",
            "types", "union_type_case", "union_type_cases", "union_type_field",
            "union_type_fields", "value_declaration", "value_declaration_left",
            "with_field_expression",
            // covered by tags.scm
            "union_type_defn",
            "for_expression",
            "application_expression",
            "import_decl",
            "while_expression",
            "match_expression",
            "record_type_defn",
            "infix_expression",
            "if_expression",
            "try_expression",
        ];
        validate_unused_kinds_audit(&FSharp, documented_unused)
            .expect("F# unused node kinds audit failed");
    }
}