use crate::db::models::{CodeElement, Relationship};
use regex;
pub struct JetpackNavExtractor<'a> {
source: &'a [u8],
file_path: &'a str,
}
impl<'a> JetpackNavExtractor<'a> {
pub fn new(source: &'a [u8], file_path: &'a str) -> Self {
Self { source, file_path }
}
pub fn extract_xml(&self) -> (Vec<CodeElement>, Vec<Relationship>) {
let content = match std::str::from_utf8(self.source) {
Ok(s) => s,
Err(_) => return (Vec::new(), Vec::new()),
};
let injected;
let content: &str = if content.contains("android:") && !content.contains("xmlns:android") {
injected = content.replacen(
"<navigation",
"<navigation xmlns:android=\"http://schemas.android.com/apk/res/android\"",
1,
);
&injected
} else {
content
};
let doc = match roxmltree::Document::parse(content) {
Ok(d) => d,
Err(_) => return (Vec::new(), Vec::new()),
};
let mut elements: Vec<CodeElement> = Vec::new();
let mut relationships: Vec<Relationship> = Vec::new();
let root = doc.root_element();
if root.tag_name().name() != "navigation" {
return (Vec::new(), Vec::new());
}
let graph_id = android_id(&root).unwrap_or_else(|| "unknown".to_string());
let graph_qn = format!("{}::nav_graph::{}", self.file_path, graph_id);
let start_dest_raw = root
.attributes()
.find(|a| {
a.name() == "startDestination"
&& a.namespace() == Some("http://schemas.android.com/apk/res-auto")
})
.map(|a| a.value().to_string());
let start_dest_id = start_dest_raw.as_deref().map(strip_id_prefix);
elements.push(CodeElement {
qualified_name: graph_qn.clone(),
element_type: "nav_graph".to_string(),
name: graph_id.clone(),
file_path: self.file_path.to_string(),
line_start: root.range().start as u32,
line_end: root.range().end as u32,
language: "xml".to_string(),
metadata: serde_json::json!({
"graph_id": graph_id,
"start_destination": start_dest_id,
}),
..Default::default()
});
const DEST_TAGS: &[&str] = &["fragment", "activity", "dialog"];
for child in root.children().filter(|n| n.is_element()) {
let tag = child.tag_name().name();
if !DEST_TAGS.contains(&tag) {
continue;
}
let dest_id = match android_id(&child) {
Some(id) => id,
None => continue,
};
let dest_qn = format!("{}::{}", graph_qn, dest_id);
let is_start = start_dest_id.map(|s| s == dest_id).unwrap_or(false);
let class_name = android_attr(&child, "name");
elements.push(CodeElement {
qualified_name: dest_qn.clone(),
element_type: "nav_destination".to_string(),
name: dest_id.clone(),
file_path: self.file_path.to_string(),
line_start: child.range().start as u32,
line_end: child.range().end as u32,
language: "xml".to_string(),
parent_qualified: Some(graph_qn.clone()),
metadata: serde_json::json!({
"destination_id": dest_id,
"dest_type": tag,
"class_name": class_name,
"start_destination": is_start,
}),
..Default::default()
});
for sub in child.children().filter(|n| n.is_element()) {
match sub.tag_name().name() {
"action" => {
let action_id = android_id(&sub);
let target_raw = app_attr(&sub, "destination");
let target_id = target_raw.as_deref().map(strip_id_prefix);
let pop_up_to = app_attr(&sub, "popUpTo");
if let Some(target) = target_id {
let target_qn = format!("{}::{}", graph_qn, target);
relationships.push(Relationship {
id: None,
source_qualified: dest_qn.clone(),
target_qualified: target_qn,
rel_type: "nav_action".to_string(),
confidence: 1.0,
metadata: serde_json::json!({
"action_id": action_id,
"pop_up_to": pop_up_to,
}),
});
}
}
"argument" => {
let arg_name = match android_attr(&sub, "name") {
Some(n) => n,
None => continue,
};
let arg_qn = format!("{}::arg::{}", dest_qn, arg_name);
let arg_type =
app_attr(&sub, "argType").unwrap_or_else(|| "string".to_string());
let nullable = app_attr(&sub, "nullable")
.map(|v| v == "true")
.unwrap_or(false);
elements.push(CodeElement {
qualified_name: arg_qn.clone(),
element_type: "nav_argument".to_string(),
name: arg_name.clone(),
file_path: self.file_path.to_string(),
line_start: sub.range().start as u32,
line_end: sub.range().end as u32,
language: "xml".to_string(),
parent_qualified: Some(dest_qn.clone()),
metadata: serde_json::json!({
"arg_type": arg_type,
"nullable": nullable,
}),
..Default::default()
});
relationships.push(Relationship {
id: None,
source_qualified: dest_qn.clone(),
target_qualified: arg_qn,
rel_type: "requires_arg".to_string(),
confidence: 1.0,
metadata: serde_json::json!({
"arg_name": arg_name,
}),
});
}
"deepLink" => {
let uri = match app_attr(&sub, "uri") {
Some(u) => u,
None => continue,
};
let dl_qn = format!("{}::deeplink::{}", dest_qn, uri);
elements.push(CodeElement {
qualified_name: dl_qn.clone(),
element_type: "nav_deep_link".to_string(),
name: uri.clone(),
file_path: self.file_path.to_string(),
line_start: sub.range().start as u32,
line_end: sub.range().end as u32,
language: "xml".to_string(),
parent_qualified: Some(dest_qn.clone()),
metadata: serde_json::json!({ "uri": uri }),
..Default::default()
});
relationships.push(Relationship {
id: None,
source_qualified: dl_qn,
target_qualified: dest_qn.clone(),
rel_type: "deep_link".to_string(),
confidence: 1.0,
metadata: serde_json::Value::Object(serde_json::Map::new()),
});
}
_ => {}
}
}
}
(elements, relationships)
}
pub fn extract_kotlin_dsl(&self) -> (Vec<CodeElement>, Vec<Relationship>) {
let content = match std::str::from_utf8(self.source) {
Ok(s) => s,
Err(_) => return (Vec::new(), Vec::new()),
};
let mut elements: Vec<CodeElement> = Vec::new();
let mut relationships: Vec<Relationship> = Vec::new();
let graph_id = "compose_nav".to_string();
let graph_qn = format!("{}::nav_graph::{}", self.file_path, graph_id);
elements.push(CodeElement {
qualified_name: graph_qn.clone(),
element_type: "nav_graph".to_string(),
name: graph_id.clone(),
file_path: self.file_path.to_string(),
line_start: 0,
line_end: 0,
language: "kotlin".to_string(),
metadata: serde_json::json!({
"graph_id": graph_id,
"dsl_type": "compose",
}),
..Default::default()
});
let composable_re = regex::Regex::new(r#"composable\s*\(\s*route\s*=\s*"([^"]+)"#)
.unwrap_or_else(|_| regex::Regex::new(r"^\x00$").unwrap());
for cap in composable_re.captures_iter(content) {
if let Some(route_match) = cap.get(1) {
let route = route_match.as_str();
let dest_id = route.to_string();
let dest_qn = format!("{}::{}", graph_qn, dest_id);
elements.push(CodeElement {
qualified_name: dest_qn.clone(),
element_type: "nav_destination".to_string(),
name: dest_id.clone(),
file_path: self.file_path.to_string(),
line_start: 0,
line_end: 0,
language: "kotlin".to_string(),
parent_qualified: Some(graph_qn.clone()),
metadata: serde_json::json!({
"destination_id": dest_id,
"dest_type": "composable",
"route": route,
}),
..Default::default()
});
}
}
let nav_re = regex::Regex::new(
r#"navigation\s*\(\s*route\s*=\s*"([^"]+)"\s*,\s*startDestination\s*=\s*"([^"]+)""#,
)
.unwrap_or_else(|_| regex::Regex::new(r"^\x00$").unwrap());
for cap in nav_re.captures_iter(content) {
if let (Some(route_match), Some(start_match)) = (cap.get(1), cap.get(2)) {
let route = route_match.as_str();
let start_dest = start_match.as_str();
let dest_id = route.to_string();
let dest_qn = format!("{}::{}", graph_qn, dest_id);
elements.push(CodeElement {
qualified_name: dest_qn.clone(),
element_type: "nav_destination".to_string(),
name: dest_id.clone(),
file_path: self.file_path.to_string(),
line_start: 0,
line_end: 0,
language: "kotlin".to_string(),
parent_qualified: Some(graph_qn.clone()),
metadata: serde_json::json!({
"destination_id": dest_id,
"dest_type": "navigation",
"route": route,
"start_destination": start_dest,
}),
..Default::default()
});
}
}
let arg_re = regex::Regex::new(r#"argument\s*\(\s*name\s*=\s*"([^"]+)"#)
.unwrap_or_else(|_| regex::Regex::new(r"^\x00$").unwrap());
for cap in arg_re.captures_iter(content) {
if let Some(arg_match) = cap.get(1) {
let arg_name = arg_match.as_str();
if let Some(first_dest_qn) = elements
.iter()
.find(|e| e.element_type == "nav_destination")
.map(|e| e.qualified_name.clone())
{
let arg_qn = format!("{}::arg::{}", first_dest_qn, arg_name);
let arg_type = "string".to_string();
let nullable = false;
elements.push(CodeElement {
qualified_name: arg_qn.clone(),
element_type: "nav_argument".to_string(),
name: arg_name.to_string(),
file_path: self.file_path.to_string(),
line_start: 0,
line_end: 0,
language: "kotlin".to_string(),
parent_qualified: Some(first_dest_qn.clone()),
metadata: serde_json::json!({
"arg_type": arg_type,
"nullable": nullable,
}),
..Default::default()
});
relationships.push(Relationship {
id: None,
source_qualified: first_dest_qn.clone(),
target_qualified: arg_qn,
rel_type: "requires_arg".to_string(),
confidence: 0.85,
metadata: serde_json::json!({
"arg_name": arg_name,
}),
});
}
}
}
let navigate_re = regex::Regex::new(r#"navigate\s*\(\s*"([^"]+)""#)
.unwrap_or_else(|_| regex::Regex::new(r"^\x00$").unwrap());
let mut seen_nav = std::collections::HashSet::new();
for cap in navigate_re.captures_iter(content) {
if let Some(dest_match) = cap.get(1) {
let target_route = dest_match.as_str();
let key = target_route.to_string();
if !seen_nav.contains(&key) && !elements.is_empty() {
seen_nav.insert(key);
if let Some(source_dest) = elements
.iter()
.find(|e| e.element_type == "nav_destination")
{
let target_qn = format!("{}::{}", graph_qn, target_route);
relationships.push(Relationship {
id: None,
source_qualified: source_dest.qualified_name.clone(),
target_qualified: target_qn,
rel_type: "nav_action".to_string(),
confidence: 0.75,
metadata: serde_json::json!({
"action_id": None::<String>,
}),
});
}
}
}
}
(elements, relationships)
}
}
const NS_ANDROID: &str = "http://schemas.android.com/apk/res/android";
const NS_APP: &str = "http://schemas.android.com/apk/res-auto";
fn android_attr(node: &roxmltree::Node, attr_name: &str) -> Option<String> {
node.attributes()
.find(|a| a.name() == attr_name && a.namespace() == Some(NS_ANDROID))
.map(|a| a.value().to_string())
}
fn app_attr(node: &roxmltree::Node, attr_name: &str) -> Option<String> {
node.attributes()
.find(|a| a.name() == attr_name && a.namespace() == Some(NS_APP))
.map(|a| a.value().to_string())
}
fn android_id(node: &roxmltree::Node) -> Option<String> {
android_attr(node, "id").map(|v| strip_id_prefix(&v).to_string())
}
fn strip_id_prefix(s: &str) -> &str {
if let Some(rest) = s.strip_prefix("@+id/") {
rest
} else if let Some(rest) = s.strip_prefix("@id/") {
rest
} else {
s
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_xml_nav_graph_destinations() {
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/nav_graph"
app:startDestination="@id/homeFragment">
<fragment
android:id="@+id/homeFragment"
android:name="com.example.HomeFragment">
<action
android:id="@+id/action_home_to_detail"
app:destination="@id/detailFragment" />
<argument
android:name="userId"
app:argType="string"
app:nullable="true" />
</fragment>
<fragment
android:id="@+id/detailFragment"
android:name="com.example.DetailFragment">
<deepLink app:uri="example://detail/{id}" />
</fragment>
</navigation>"#;
let extractor = JetpackNavExtractor::new(xml.as_bytes(), "res/navigation/nav_graph.xml");
let (elements, relationships) = extractor.extract_xml();
let destinations: Vec<_> = elements
.iter()
.filter(|e| e.element_type == "nav_destination")
.collect();
assert_eq!(destinations.len(), 2, "Should find 2 destinations");
assert!(destinations.iter().any(|e| e.name == "homeFragment"));
assert!(destinations.iter().any(|e| e.name == "detailFragment"));
let actions: Vec<_> = relationships
.iter()
.filter(|r| r.rel_type == "nav_action")
.collect();
assert_eq!(actions.len(), 1, "Should find 1 action");
let args: Vec<_> = elements
.iter()
.filter(|e| e.element_type == "nav_argument")
.collect();
assert_eq!(args.len(), 1, "Should find 1 argument (userId)");
let deep_links: Vec<_> = relationships
.iter()
.filter(|r| r.rel_type == "deep_link")
.collect();
assert_eq!(deep_links.len(), 1, "Should find 1 deep link");
}
#[test]
fn test_xml_nav_start_destination() {
let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/nav_main"
app:startDestination="@id/loginFragment">
<fragment android:id="@+id/loginFragment" android:name="com.example.LoginFragment" />
<fragment android:id="@+id/dashboardFragment" android:name="com.example.DashboardFragment" />
</navigation>"#;
let extractor = JetpackNavExtractor::new(xml.as_bytes(), "res/navigation/nav_main.xml");
let (elements, _) = extractor.extract_xml();
let nav_graph = elements.iter().find(|e| e.element_type == "nav_graph");
assert!(nav_graph.is_some(), "Should have a nav_graph element");
let start = elements
.iter()
.find(|e| e.element_type == "nav_destination" && e.name == "loginFragment");
assert!(start.is_some());
assert_eq!(
start
.unwrap()
.metadata
.get("start_destination")
.and_then(|v| v.as_bool()),
Some(true),
"loginFragment should be marked as start destination"
);
}
}