leankg 0.16.7

Lightweight Knowledge Graph for AI-Assisted Development
Documentation
#![allow(clippy::regex_creation_in_loops)]

use crate::db::models::{CodeElement, Relationship};
use regex::Regex;
use std::sync::OnceLock;

static BROWSE_FRAGMENT_RE: OnceLock<Regex> = OnceLock::new();
static ITEM_CLICKED_RE: OnceLock<Regex> = OnceLock::new();
static DETAILS_ACTIVITY_RE: OnceLock<Regex> = OnceLock::new();
static FRAGMENT_RE: OnceLock<Regex> = OnceLock::new();

pub struct LeanbackNavExtractor<'a> {
    source: &'a [u8],
    file_path: &'a str,
}

impl<'a> LeanbackNavExtractor<'a> {
    pub fn new(source: &'a [u8], file_path: &'a str) -> Self {
        Self { source, file_path }
    }

    pub fn extract(&self) -> (Vec<CodeElement>, Vec<Relationship>) {
        let content = match std::str::from_utf8(self.source) {
            Ok(s) => s,
            Err(_) => return (Vec::new(), Vec::new()),
        };

        let browse_re = BROWSE_FRAGMENT_RE.get_or_init(|| {
            Regex::new(r"class\s+(\w+)\s*:\s*(?:BrowseSupportFragment|BrowseFragment|VerticalGridSupportFragment)\s*\(").unwrap()
        });
        let details_re = DETAILS_ACTIVITY_RE.get_or_init(|| {
            Regex::new(r"startActivity\s*\(\s*Intent\s*\([^,]+,\s*(\w+Activity)::class\.java\s*\)")
                .unwrap()
        });
        let item_clicked_re =
            ITEM_CLICKED_RE.get_or_init(|| Regex::new(r"setOnItemViewClickedListener\b").unwrap());
        let frag_re = FRAGMENT_RE.get_or_init(|| Regex::new(r"(\w+Fragment)\s*\(").unwrap());

        let mut elements = Vec::new();
        let mut relationships = Vec::new();

        for cap in browse_re.captures_iter(content) {
            if let Some(class_match) = cap.get(1) {
                let class_name = class_match.as_str();
                let elem_qn = format!("{}::{}", self.file_path, class_name);

                elements.push(CodeElement {
                    qualified_name: elem_qn.clone(),
                    element_type: "nav_destination".to_string(),
                    name: class_name.to_string(),
                    file_path: self.file_path.to_string(),
                    language: "kotlin".to_string(),
                    metadata: serde_json::json!({
                        "dest_type": "leanback_browse",
                        "class_name": class_name,
                    }),
                    ..Default::default()
                });

                for dcap in details_re.captures_iter(content) {
                    if let Some(activity_match) = dcap.get(1) {
                        let activity_name = activity_match.as_str();
                        relationships.push(Relationship {
                            id: None,
                            source_qualified: elem_qn.clone(),
                            target_qualified: format!("class:{}", activity_name),
                            rel_type: "presents".to_string(),
                            confidence: 0.80,
                            metadata: serde_json::json!({
                                "nav_type": "leanback_browse_to_details",
                                "activity_name": activity_name,
                            }),
                        });
                    }
                }

                if item_clicked_re.is_match(content) {
                    let listener_pos = item_clicked_re
                        .find(content)
                        .map(|m| m.start())
                        .unwrap_or(0);
                    let window =
                        &content[listener_pos..std::cmp::min(listener_pos + 500, content.len())];
                    for fcap in frag_re.captures_iter(window) {
                        if let Some(fm) = fcap.get(1) {
                            let frag_name = fm.as_str();
                            if frag_name != class_name {
                                relationships.push(Relationship {
                                    id: None,
                                    source_qualified: elem_qn.clone(),
                                    target_qualified: format!("class:{}", frag_name),
                                    rel_type: "presents".to_string(),
                                    confidence: 0.75,
                                    metadata: serde_json::json!({
                                        "nav_type": "leanback_item_click",
                                        "fragment_name": frag_name,
                                    }),
                                });
                            }
                        }
                    }
                }
            }
        }

        (elements, relationships)
    }
}

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

    #[test]
    fn test_browse_fragment_detected() {
        let source = r#"
class MainFragment : BrowseSupportFragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        setOnItemViewClickedListener { _, item, _, _ ->
            startActivity(Intent(activity, DetailsActivity::class.java))
        }
    }
}
"#;
        let extractor = LeanbackNavExtractor::new(source.as_bytes(), "tv/MainFragment.kt");
        let (elements, relationships) = extractor.extract();

        let browse_elem = elements
            .iter()
            .find(|e| e.element_type == "nav_destination" && e.name.contains("MainFragment"));
        assert!(
            browse_elem.is_some(),
            "Should detect BrowseSupportFragment as nav_destination"
        );

        let nav_rels: Vec<_> = relationships
            .iter()
            .filter(|r| r.rel_type == "presents")
            .collect();
        assert!(!nav_rels.is_empty(), "Should find presents relationship");
        assert!(nav_rels[0].target_qualified.contains("DetailsActivity"));
    }

    #[test]
    fn test_non_leanback_not_detected() {
        let source = r#"
class RegularFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {}
}
"#;
        let extractor = LeanbackNavExtractor::new(source.as_bytes(), "ui/RegularFragment.kt");
        let (elements, relationships) = extractor.extract();

        assert!(
            elements.is_empty(),
            "Non-leanback fragment should produce no elements"
        );
        assert!(relationships.is_empty());
    }
}