car-ast 0.15.2

Tree-sitter AST parsing for code-aware inference
Documentation
use super::node_text;
use crate::types::*;

pub fn extract(tree: &tree_sitter::Tree, source: &[u8]) -> (Vec<Symbol>, Vec<Import>) {
    let root = tree.root_node();
    let mut symbols = Vec::new();
    let mut imports = Vec::new();
    let mut cursor = root.walk();

    for child in root.children(&mut cursor) {
        match child.kind() {
            "rule_set" => {
                extract_rule_set(&child, source, &mut symbols);
            }
            "import_statement" => {
                extract_import(&child, source, &mut imports);
            }
            "keyframes_statement" => {
                extract_keyframes(&child, source, &mut symbols);
            }
            "media_statement" => {
                extract_media(&child, source, &mut symbols);
            }
            _ => {}
        }
    }

    (symbols, imports)
}

fn extract_rule_set(node: &tree_sitter::Node, source: &[u8], symbols: &mut Vec<Symbol>) {
    // Find the selectors child
    let mut cursor = node.walk();
    for child in node.children(&mut cursor) {
        if child.kind() == "selectors" {
            let text = node_text(&child, source);
            // Extract individual selectors (class and id)
            for selector in text.split(',') {
                let selector = selector.trim();
                if selector.starts_with('.') || selector.starts_with('#') {
                    symbols.push(Symbol {
                        name: selector.to_string(),
                        kind: SymbolKind::Const,
                        span: Span::from_node(node),
                        signature: selector.to_string(),
                        doc_comment: None,
                        parent: None,
                        children: Vec::new(),
                    });
                }
            }
        }
    }
}

fn extract_import(node: &tree_sitter::Node, source: &[u8], imports: &mut Vec<Import>) {
    // @import url("...") or @import "..."
    let text = node_text(node, source);
    // Extract the path from the import statement
    let path = extract_import_path(text);
    imports.push(Import {
        path,
        alias: None,
        span: Span::from_node(node),
    });
}

fn extract_import_path(text: &str) -> String {
    // Try url("...") first
    if let Some(start) = text.find("url(") {
        let rest = &text[start + 4..];
        let inner = rest.trim_start_matches(|c: char| c == '"' || c == '\'');
        if let Some(end) = inner.find(|c: char| c == '"' || c == '\'' || c == ')') {
            return inner[..end].to_string();
        }
    }
    // Try bare string: @import "..." or @import '...'
    if let Some(start) = text.find('"') {
        if let Some(end) = text[start + 1..].find('"') {
            return text[start + 1..start + 1 + end].to_string();
        }
    }
    if let Some(start) = text.find('\'') {
        if let Some(end) = text[start + 1..].find('\'') {
            return text[start + 1..start + 1 + end].to_string();
        }
    }
    text.to_string()
}

fn extract_keyframes(node: &tree_sitter::Node, source: &[u8], symbols: &mut Vec<Symbol>) {
    // @keyframes name { ... }
    // tree-sitter-css uses "keyframes_name" child node, not a field
    let name = find_child_by_kind(node, "keyframes_name").map(|n| node_text(&n, source));
    if let Some(name) = name {
        symbols.push(Symbol {
            name: name.to_string(),
            kind: SymbolKind::Function,
            span: Span::from_node(node),
            signature: format!("@keyframes {}", name),
            doc_comment: None,
            parent: None,
            children: Vec::new(),
        });
    }
}

fn find_child_by_kind<'a>(
    node: &'a tree_sitter::Node<'a>,
    kind: &str,
) -> Option<tree_sitter::Node<'a>> {
    let mut cursor = node.walk();
    for child in node.children(&mut cursor) {
        if child.kind() == kind {
            return Some(child);
        }
    }
    None
}

fn extract_media(node: &tree_sitter::Node, source: &[u8], symbols: &mut Vec<Symbol>) {
    // @media (...) { ... }
    // Collect everything before the block as the query
    let text = node_text(node, source);
    let query = if let Some(brace) = text.find('{') {
        text[..brace].trim().to_string()
    } else {
        text.to_string()
    };

    symbols.push(Symbol {
        name: query.clone(),
        kind: SymbolKind::Module,
        span: Span::from_node(node),
        signature: query,
        doc_comment: None,
        parent: None,
        children: Vec::new(),
    });
}

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

    fn parse_css(source: &str) -> (Vec<Symbol>, Vec<Import>) {
        let mut parser = tree_sitter::Parser::new();
        parser
            .set_language(&tree_sitter_css::LANGUAGE.into())
            .unwrap();
        let tree = parser.parse(source, None).unwrap();
        extract(&tree, source.as_bytes())
    }

    #[test]
    fn test_css_extraction() {
        let source = r#"
@import "reset.css";
@import url("fonts.css");

.container {
    display: flex;
}

#header {
    background: blue;
}

@keyframes fadeIn {
    from { opacity: 0; }
    to { opacity: 1; }
}

@media (max-width: 768px) {
    .container {
        flex-direction: column;
    }
}
"#;

        let (symbols, imports) = parse_css(source);

        // Imports
        assert_eq!(imports.len(), 2);
        assert!(imports.iter().any(|i| i.path == "reset.css"));
        assert!(imports.iter().any(|i| i.path == "fonts.css"));

        // Selectors as Const
        let consts: Vec<_> = symbols
            .iter()
            .filter(|s| s.kind == SymbolKind::Const)
            .collect();
        assert!(consts.iter().any(|s| s.name == ".container"));
        assert!(consts.iter().any(|s| s.name == "#header"));

        // @keyframes as Function
        let funcs: Vec<_> = symbols
            .iter()
            .filter(|s| s.kind == SymbolKind::Function)
            .collect();
        assert_eq!(funcs.len(), 1);
        assert_eq!(funcs[0].name, "fadeIn");

        // @media as Module
        let modules: Vec<_> = symbols
            .iter()
            .filter(|s| s.kind == SymbolKind::Module)
            .collect();
        assert_eq!(modules.len(), 1);
        assert!(modules[0].name.contains("@media"));
    }
}