frigg 0.3.2

Local-first MCP server for code understanding.
Documentation
use std::path::Path;

use tree_sitter::Node;

use crate::indexer::SymbolKind;

use super::registry::node_name_text;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum VariableDeclarationKind {
    Const,
    Let,
    Var,
}

pub(crate) fn is_typescript_path(path: &Path) -> bool {
    path.extension()
        .and_then(|extension| extension.to_str())
        .is_some_and(|extension| matches!(extension.to_ascii_lowercase().as_str(), "ts" | "tsx"))
}

pub(crate) fn is_tsx_path(path: &Path) -> bool {
    path.extension()
        .and_then(|extension| extension.to_str())
        .is_some_and(|extension| extension.eq_ignore_ascii_case("tsx"))
}

pub(super) fn symbol_from_node(source: &str, node: Node<'_>) -> Option<(SymbolKind, String)> {
    match node.kind() {
        "internal_module" | "module" => {
            normalized_name_text(node, source).map(|name| (SymbolKind::Module, name))
        }
        "abstract_class_declaration" | "class_declaration" => {
            normalized_name_text(node, source).map(|name| (SymbolKind::Class, name))
        }
        "interface_declaration" => {
            normalized_name_text(node, source).map(|name| (SymbolKind::Interface, name))
        }
        "enum_declaration" => {
            normalized_name_text(node, source).map(|name| (SymbolKind::Enum, name))
        }
        "type_alias_declaration" => {
            normalized_name_text(node, source).map(|name| (SymbolKind::TypeAlias, name))
        }
        "function_declaration" | "generator_function_declaration" => {
            normalized_name_text(node, source).map(|name| (SymbolKind::Function, name))
        }
        "method_definition" | "method_signature" | "abstract_method_signature" => {
            normalized_name_text(node, source).map(|name| (SymbolKind::Method, name))
        }
        "public_field_definition" | "property_signature" => {
            normalized_name_text(node, source).map(|name| (SymbolKind::Property, name))
        }
        "variable_declarator" => variable_declarator_symbol(source, node),
        _ => None,
    }
}

fn variable_declarator_symbol(source: &str, node: Node<'_>) -> Option<(SymbolKind, String)> {
    if !is_supported_module_scope_variable(node) {
        return None;
    }

    let name_node = node.child_by_field_name("name")?;
    if name_node.kind() != "identifier" {
        return None;
    }
    let name = name_node.utf8_text(source.as_bytes()).ok()?.trim();
    if name.is_empty() {
        return None;
    }

    let kind = match node.child_by_field_name("value").map(|value| value.kind()) {
        Some("arrow_function" | "function_expression") => SymbolKind::Function,
        Some("class") => SymbolKind::Class,
        _ => match variable_declaration_kind(source, node) {
            Some(VariableDeclarationKind::Const) => SymbolKind::Const,
            Some(VariableDeclarationKind::Let | VariableDeclarationKind::Var) | None => {
                return None;
            }
        },
    };

    Some((kind, name.to_owned()))
}

fn variable_declaration_kind(source: &str, node: Node<'_>) -> Option<VariableDeclarationKind> {
    let declaration = node.parent()?;
    let text = declaration.utf8_text(source.as_bytes()).ok()?.trim_start();
    if text.starts_with("const ") {
        return Some(VariableDeclarationKind::Const);
    }
    if text.starts_with("let ") {
        return Some(VariableDeclarationKind::Let);
    }
    if text.starts_with("var ") {
        return Some(VariableDeclarationKind::Var);
    }
    None
}

fn is_supported_module_scope_variable(node: Node<'_>) -> bool {
    let declaration = match node.parent() {
        Some(parent)
            if matches!(
                parent.kind(),
                "lexical_declaration" | "variable_declaration"
            ) =>
        {
            parent
        }
        _ => return false,
    };

    let Some(mut container) = declaration.parent() else {
        return false;
    };
    while matches!(container.kind(), "export_statement" | "ambient_declaration") {
        let Some(parent) = container.parent() else {
            return false;
        };
        container = parent;
    }

    match container.kind() {
        "program" | "module" | "internal_module" => true,
        "statement_block" => container
            .parent()
            .is_some_and(|parent| matches!(parent.kind(), "module" | "internal_module")),
        _ => false,
    }
}

fn normalized_name_text(node: Node<'_>, source: &str) -> Option<String> {
    let raw = node_name_text(node, source)?;
    normalize_name(&raw)
}

fn normalize_name(raw: &str) -> Option<String> {
    let trimmed = raw.trim();
    if trimmed.is_empty() || trimmed.starts_with('[') {
        return None;
    }

    for quote in ['"', '\'', '`'] {
        if trimmed.starts_with(quote) && trimmed.ends_with(quote) && trimmed.len() >= 2 {
            let inner = trimmed.trim_matches(quote).trim();
            return (!inner.is_empty()).then(|| inner.to_owned());
        }
    }

    Some(trimmed.to_owned())
}