use std::{collections::HashMap, path::Path};
use sqry_core::graph::unified::edge::kind::TypeOfContext;
use sqry_core::graph::unified::{ExportKind, GraphBuildHelper, StagingGraph};
use sqry_core::graph::{GraphBuilder, GraphBuilderError, GraphResult, Language, Span};
use tree_sitter::{Node, StreamingIterator, Tree};
use super::type_extractor::{
extract_all_type_names_from_elixir_type, extract_type_string, is_type_node,
};
#[derive(Debug, Clone, Copy)]
pub struct ElixirGraphBuilder {
max_scope_depth: usize,
}
impl Default for ElixirGraphBuilder {
fn default() -> Self {
Self {
max_scope_depth: 3, }
}
}
impl ElixirGraphBuilder {
#[must_use]
pub fn new(max_scope_depth: usize) -> Self {
Self { max_scope_depth }
}
}
impl GraphBuilder for ElixirGraphBuilder {
fn build_graph(
&self,
tree: &Tree,
content: &[u8],
file: &Path,
staging: &mut StagingGraph,
) -> GraphResult<()> {
let mut helper = GraphBuildHelper::new(staging, file, Language::Elixir);
let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
GraphBuilderError::ParseError {
span: Span::default(),
reason: e,
}
})?;
let mut protocol_map = HashMap::new();
collect_protocols(tree.root_node(), content, &mut helper, &mut protocol_map)?;
let recursion_limits =
sqry_core::config::RecursionLimits::load_or_default().map_err(|e| {
GraphBuilderError::ParseError {
span: Span::default(),
reason: format!("Failed to load recursion limits: {e}"),
}
})?;
let file_ops_depth = recursion_limits.effective_file_ops_depth().map_err(|e| {
GraphBuilderError::ParseError {
span: Span::default(),
reason: format!("Invalid file_ops_depth configuration: {e}"),
}
})?;
let mut guard =
sqry_core::query::security::RecursionGuard::new(file_ops_depth).map_err(|e| {
GraphBuilderError::ParseError {
span: Span::default(),
reason: format!("Failed to create recursion guard: {e}"),
}
})?;
walk_tree_for_graph(
tree.root_node(),
content,
&ast_graph,
&mut helper,
&protocol_map,
&mut guard,
)?;
Ok(())
}
fn language(&self) -> Language {
Language::Elixir
}
}
fn collect_protocols(
node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
protocol_map: &mut HashMap<String, sqry_core::graph::unified::NodeId>,
) -> GraphResult<()> {
if node.kind() == "call"
&& is_protocol_definition(&node, content)
&& let Some(protocol_id) = build_protocol_node(node, content, helper)?
{
let mut node_cursor = node.walk();
for child in node.children(&mut node_cursor) {
if child.kind() == "arguments" {
let mut args_cursor = child.walk();
for arg_child in child.children(&mut args_cursor) {
if (arg_child.kind() == "identifier" || arg_child.kind() == "alias")
&& let Ok(name) = arg_child.utf8_text(content)
{
protocol_map.insert(name.to_string(), protocol_id);
break;
}
}
break;
}
}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_protocols(child, content, helper, protocol_map)?;
}
Ok(())
}
fn walk_tree_for_graph(
node: Node,
content: &[u8],
ast_graph: &ASTGraph,
helper: &mut GraphBuildHelper,
protocol_map: &HashMap<String, sqry_core::graph::unified::NodeId>,
guard: &mut sqry_core::query::security::RecursionGuard,
) -> GraphResult<()> {
guard.enter().map_err(|e| GraphBuilderError::ParseError {
span: Span::default(),
reason: format!("Recursion limit exceeded: {e}"),
})?;
if node.kind() == "unary_operator" && is_spec_annotation(&node, content) {
process_spec_typeof_edges(node, content, helper)?;
}
if node.kind() == "call" && is_protocol_implementation(&node, content) {
build_protocol_impl(node, content, helper, protocol_map)?;
}
else if is_function_definition(&node, content) {
if let Some(context) = ast_graph.get_callable_context(node.id()) {
let span = span_from_node(node);
let visibility = if context.is_private {
"private"
} else {
"public"
};
let function_id = helper.add_function_with_visibility(
&context.qualified_name,
Some(span),
false, false, Some(visibility),
);
if !context.is_private {
let module_id = helper.add_module("<module>", None);
helper.add_export_edge_full(module_id, function_id, ExportKind::Direct, None);
}
}
}
if node.kind() == "call" && is_erlang_load_nif(&node, content) {
build_nif_ffi_edge(node, content, ast_graph, helper);
}
else if node.kind() == "call" && is_import_statement(&node, content) {
build_import_edge_with_helper(node, content, helper)?;
}
else if node.kind() == "call"
&& !is_function_definition(&node, content)
&& let Ok(Some((caller_id, callee_id, argument_count, span))) =
build_call_edge_with_helper(ast_graph, node, content, helper)
{
let argument_count = u8::try_from(argument_count).unwrap_or(u8::MAX);
helper.add_call_edge_full_with_span(
caller_id,
callee_id,
argument_count,
false,
vec![span],
);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
walk_tree_for_graph(child, content, ast_graph, helper, protocol_map, guard)?;
}
guard.exit();
Ok(())
}
fn build_call_edge_with_helper(
ast_graph: &ASTGraph,
call_node: Node<'_>,
content: &[u8],
helper: &mut GraphBuildHelper,
) -> GraphResult<
Option<(
sqry_core::graph::unified::NodeId,
sqry_core::graph::unified::NodeId,
usize,
Span,
)>,
> {
let module_context;
let call_context = if let Some(ctx) = ast_graph.get_callable_context(call_node.id()) {
ctx
} else {
module_context = CallContext {
qualified_name: "<module>".to_string(),
span: (0, content.len()),
is_private: false,
};
&module_context
};
let Some(target_node) = call_node.child_by_field_name("target") else {
return Ok(None);
};
let (callee_text, _is_erlang_ffi) = extract_call_info(&target_node, content)?;
if callee_text.is_empty() {
return Ok(None);
}
let caller_fn_id = helper.add_function(&call_context.qualified_name, None, false, false);
let target_fn_id = helper.add_function(&callee_text, None, false, false);
let call_span = span_from_node(call_node);
let argument_count = count_arguments(call_node);
Ok(Some((
caller_fn_id,
target_fn_id,
argument_count,
call_span,
)))
}
#[allow(dead_code)] fn extract_module_name(tree: &Tree, content: &[u8]) -> Option<String> {
let query = tree_sitter::Query::new(
&tree_sitter_elixir_sqry::language(),
r#"(call
target: (identifier) @def
(arguments
(alias) @module_name)
(#eq? @def "defmodule")
)"#,
)
.ok()?;
let mut cursor = tree_sitter::QueryCursor::new();
let root = tree.root_node();
let mut matches = cursor.matches(&query, root, content);
if let Some(m) = matches.next() {
for capture in m.captures {
if query.capture_names()[capture.index as usize] == "module_name" {
return capture.node.utf8_text(content).ok().map(String::from);
}
}
}
None
}
fn is_function_definition(call_node: &Node, content: &[u8]) -> bool {
if let Some(target) = call_node.child_by_field_name("target")
&& let Ok(target_text) = target.utf8_text(content)
{
return matches!(target_text, "def" | "defp" | "defmacro" | "defmacrop");
}
false
}
fn is_import_statement(call_node: &Node, content: &[u8]) -> bool {
if let Some(target) = call_node.child_by_field_name("target")
&& let Ok(target_text) = target.utf8_text(content)
{
return matches!(target_text, "import" | "alias" | "use" | "require");
}
false
}
fn is_protocol_definition(call_node: &Node, content: &[u8]) -> bool {
if let Some(target) = call_node.child_by_field_name("target")
&& let Ok(target_text) = target.utf8_text(content)
{
return target_text == "defprotocol";
}
false
}
fn is_protocol_implementation(call_node: &Node, content: &[u8]) -> bool {
if let Some(target) = call_node.child_by_field_name("target")
&& let Ok(target_text) = target.utf8_text(content)
{
return target_text == "defimpl";
}
false
}
fn is_spec_annotation(node: &Node, content: &[u8]) -> bool {
if node.kind() != "unary_operator" {
return false;
}
if let Some(call_node) = node.named_child(0)
&& call_node.kind() == "call"
&& let Some(target) = call_node.named_child(0)
&& let Ok(target_text) = target.utf8_text(content)
{
return target_text == "spec" || target_text == "type";
}
false
}
#[allow(clippy::unnecessary_wraps)]
fn process_spec_typeof_edges(
spec_node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
) -> GraphResult<()> {
if let Some(call_node) = spec_node.named_child(0)
&& call_node.kind() == "call"
&& let Some(args_node) = call_node.named_child(1)
&& args_node.kind() == "arguments"
&& let Some(binary_op) = args_node.named_child(0)
&& binary_op.kind() == "binary_operator"
&& let Some(func_call) = binary_op.named_child(0)
{
let func_name = if let Some(target) = func_call.named_child(0) {
target.utf8_text(content).ok().map(String::from)
} else {
None
};
if let Some(func_name) = func_name {
let function_id = helper.add_function(&func_name, None, false, false);
if let Some(param_args) = func_call.named_child(1)
&& param_args.kind() == "arguments"
{
let mut param_index: u16 = 0;
let mut cursor = param_args.walk();
for param_type_node in param_args.named_children(&mut cursor) {
if is_type_node(param_type_node.kind()) {
if let Some(type_text) = extract_type_string(param_type_node, content) {
let type_id = helper.add_type(&type_text, None);
helper.add_typeof_edge_with_context(
function_id,
type_id,
Some(TypeOfContext::Parameter),
Some(param_index),
None,
);
}
let referenced_types =
extract_all_type_names_from_elixir_type(param_type_node, content);
for ref_type_name in referenced_types {
let ref_type_id = helper.add_type(&ref_type_name, None);
helper.add_reference_edge(function_id, ref_type_id);
}
param_index += 1;
}
}
}
if let Some(return_type_node) = binary_op.named_child(1)
&& is_type_node(return_type_node.kind())
{
if let Some(type_text) = extract_type_string(return_type_node, content) {
let type_id = helper.add_type(&type_text, None);
helper.add_typeof_edge_with_context(
function_id,
type_id,
Some(TypeOfContext::Return),
Some(0),
None,
);
}
let referenced_types =
extract_all_type_names_from_elixir_type(return_type_node, content);
for ref_type_name in referenced_types {
let ref_type_id = helper.add_type(&ref_type_name, None);
helper.add_reference_edge(function_id, ref_type_id);
}
}
}
}
Ok(())
}
#[allow(clippy::unnecessary_wraps)]
fn build_protocol_node(
protocol_node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
) -> GraphResult<Option<sqry_core::graph::unified::NodeId>> {
let mut cursor = protocol_node.walk();
for child in protocol_node.children(&mut cursor) {
if child.kind() == "arguments" {
let mut args_cursor = child.walk();
for arg_child in child.children(&mut args_cursor) {
if (arg_child.kind() == "alias" || arg_child.kind() == "identifier")
&& let Ok(name) = arg_child.utf8_text(content)
{
let span = span_from_node(protocol_node);
let protocol_id = helper.add_interface(name, Some(span));
return Ok(Some(protocol_id));
}
}
}
}
Ok(None)
}
#[allow(clippy::unnecessary_wraps)]
fn build_protocol_impl(
impl_node: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
protocol_map: &HashMap<String, sqry_core::graph::unified::NodeId>,
) -> GraphResult<()> {
let mut impl_cursor = impl_node.walk();
for child in impl_node.children(&mut impl_cursor) {
if child.kind() == "arguments" {
let mut protocol_name = None;
let mut target_type = None;
let mut cursor = child.walk();
let mut found_protocol = false;
for arg_child in child.children(&mut cursor) {
if !found_protocol
&& (arg_child.kind() == "identifier" || arg_child.kind() == "alias")
{
if let Ok(name) = arg_child.utf8_text(content) {
protocol_name = Some(name.to_string());
found_protocol = true;
}
}
else if arg_child.kind() == "keywords" {
let mut kw_cursor = arg_child.walk();
for kw_child in arg_child.children(&mut kw_cursor) {
if kw_child.kind() == "pair" {
if let Some(key) = kw_child.child_by_field_name("key")
&& let Ok(key_text) = key.utf8_text(content)
{
let key_trimmed = key_text.trim().trim_end_matches(':');
if key_trimmed == "for" {
if let Some(value) = kw_child.child_by_field_name("value") {
if let Ok(type_name) = value.utf8_text(content) {
target_type = Some(type_name.to_string());
}
} else {
let mut pair_cursor = kw_child.walk();
for pair_child in kw_child.children(&mut pair_cursor) {
if (pair_child.kind() == "alias"
|| pair_child.kind() == "identifier")
&& let Ok(type_name) = pair_child.utf8_text(content)
&& type_name != "for:"
&& type_name != "for"
{
target_type = Some(type_name.to_string());
break;
}
}
}
}
}
}
}
}
}
if let (Some(protocol), Some(target)) = (protocol_name, target_type) {
let span = span_from_node(impl_node);
let impl_name = format!("{protocol}.{target}");
let impl_id = helper.add_struct(&impl_name, Some(span));
if let Some(&protocol_id) = protocol_map.get(&protocol) {
helper.add_implements_edge(impl_id, protocol_id);
} else {
let protocol_id = helper.add_interface(&protocol, None);
helper.add_implements_edge(impl_id, protocol_id);
}
}
break;
}
}
Ok(())
}
#[allow(clippy::too_many_lines)] #[allow(clippy::unnecessary_wraps)] fn build_import_edge_with_helper(
call_node: Node<'_>,
content: &[u8],
helper: &mut GraphBuildHelper,
) -> GraphResult<()> {
let Some(target) = call_node.child_by_field_name("target") else {
return Ok(());
};
let import_type = target.utf8_text(content).unwrap_or("");
let mut cursor = call_node.walk();
let args_node = call_node
.children(&mut cursor)
.find(|c| c.kind() == "arguments");
let Some(args_node) = args_node else {
return Ok(());
};
let mut cursor = args_node.walk();
let mut module_name: Option<String> = None;
let mut alias_name: Option<String> = None;
let mut is_wildcard = matches!(import_type, "import" | "use");
let mut has_only_or_except = false;
for child in args_node.named_children(&mut cursor) {
match child.kind() {
"alias" => {
if module_name.is_none()
&& let Ok(text) = child.utf8_text(content)
{
module_name = Some(text.to_string());
if import_type == "alias"
&& alias_name.is_none()
&& let Some(last_segment) = text.rsplit('.').next()
{
alias_name = Some(last_segment.to_string());
}
}
}
"dot" => {
if import_type == "alias" {
let mut dot_cursor = child.walk();
let mut base_module: Option<String> = None;
let mut tuple_elements: Vec<String> = Vec::new();
for dot_child in child.named_children(&mut dot_cursor) {
match dot_child.kind() {
"alias" => {
if base_module.is_none()
&& let Ok(text) = dot_child.utf8_text(content)
{
base_module = Some(text.to_string());
}
}
"tuple" => {
let mut tuple_cursor = dot_child.walk();
for tuple_elem in dot_child.named_children(&mut tuple_cursor) {
if tuple_elem.kind() == "alias"
&& let Ok(text) = tuple_elem.utf8_text(content)
{
tuple_elements.push(text.to_string());
}
}
}
_ => {}
}
}
if !tuple_elements.is_empty() {
let span = span_from_node(call_node);
let module_id = helper.add_module("<module>", None);
let base = base_module.unwrap_or_default();
for element in tuple_elements {
let full_module = if base.is_empty() {
element.clone()
} else {
format!("{base}.{element}")
};
let alias_value = element.clone();
let import_id = helper.add_import(&full_module, Some(span));
helper.add_import_edge_full(
module_id,
import_id,
Some(&alias_value),
false,
);
}
return Ok(());
}
}
if let Ok(text) = child.utf8_text(content) {
module_name = Some(text.to_string());
if import_type == "alias"
&& alias_name.is_none()
&& let Some(last_segment) = text.rsplit('.').next()
{
alias_name = Some(last_segment.to_string());
}
}
}
"tuple" => {
if import_type == "alias" {
let mut tuple_cursor = child.walk();
let tuple_elements: Vec<String> = child
.named_children(&mut tuple_cursor)
.filter_map(|elem| {
if elem.kind() == "alias" {
elem.utf8_text(content).ok().map(String::from)
} else {
None
}
})
.collect();
if !tuple_elements.is_empty() {
let span = span_from_node(call_node);
let module_id = helper.add_module("<module>", None);
for element in tuple_elements {
let import_id = helper.add_import(&element, Some(span));
helper.add_import_edge_full(
module_id,
import_id,
Some(&element),
false,
);
}
return Ok(());
}
}
is_wildcard = true;
}
"keywords" => {
let mut kw_cursor = child.walk();
for kw_pair in child.named_children(&mut kw_cursor) {
if kw_pair.kind() == "pair" {
let mut pair_cursor = kw_pair.walk();
let mut key: Option<String> = None;
let mut value: Option<String> = None;
for pair_child in kw_pair.named_children(&mut pair_cursor) {
match pair_child.kind() {
"keyword" | "atom" => {
if key.is_none()
&& let Ok(text) = pair_child.utf8_text(content)
{
key = Some(text.trim().trim_end_matches(':').to_string());
}
}
"alias" | "identifier" => {
if value.is_none()
&& let Ok(text) = pair_child.utf8_text(content)
{
value = Some(text.to_string());
}
}
"list" => {
has_only_or_except = true;
is_wildcard = false;
}
_ => {}
}
}
if key.as_deref() == Some("as") {
alias_name = value;
} else if key.as_deref() == Some("only") || key.as_deref() == Some("except")
{
has_only_or_except = true;
is_wildcard = false;
}
}
}
}
_ => {}
}
}
let _ = has_only_or_except;
if let Some(imported_module) = module_name {
let span = span_from_node(call_node);
let module_id = helper.add_module("<module>", None);
let import_name = match import_type {
"use" => format!("use:{imported_module}"),
"require" => format!("require:{imported_module}"),
_ => imported_module.clone(),
};
let import_id = helper.add_import(&import_name, Some(span));
helper.add_import_edge_full(module_id, import_id, alias_name.as_deref(), is_wildcard);
}
Ok(())
}
fn count_arguments(call_node: Node<'_>) -> usize {
if let Some(args_node) = call_node.child_by_field_name("arguments") {
let mut cursor = args_node.walk();
let children: Vec<_> = args_node.named_children(&mut cursor).collect();
let count = children
.iter()
.filter(|child| {
!matches!(child.kind(), "," | "(" | ")" | "[" | "]")
})
.count();
tracing::trace!(
"count_arguments: call_node.kind={}, args_node.kind={}, children={:?}, count={}",
call_node.kind(),
args_node.kind(),
children
.iter()
.map(tree_sitter::Node::kind)
.collect::<Vec<_>>(),
count
);
count
} else {
let mut cursor = call_node.walk();
let children: Vec<_> = call_node
.named_children(&mut cursor)
.filter(|child| {
matches!(child.kind(), "arguments" | "argument_list")
})
.collect();
if let Some(arg_list) = children.first() {
let mut arg_cursor = arg_list.walk();
let args: Vec<_> = arg_list.named_children(&mut arg_cursor).collect();
let count = args
.iter()
.filter(|child| !matches!(child.kind(), "," | "(" | ")" | "[" | "]"))
.count();
tracing::trace!(
"count_arguments (fallback): found argument_list, args={:?}, count={}",
args.iter().map(tree_sitter::Node::kind).collect::<Vec<_>>(),
count
);
count
} else {
tracing::trace!(
"count_arguments: no arguments field or argument_list found for call_node.kind={}",
call_node.kind()
);
0
}
}
}
fn extract_call_info(target_node: &Node, content: &[u8]) -> GraphResult<(String, bool)> {
match target_node.kind() {
"identifier" => {
let name = target_node
.utf8_text(content)
.map_err(|_| GraphBuilderError::ParseError {
span: span_from_node(*target_node),
reason: "failed to read call identifier".to_string(),
})?
.to_string();
Ok((name, false))
}
"dot" => {
if let Some(left) = target_node.child_by_field_name("left") {
let left_text = left.utf8_text(content).unwrap_or("");
let is_erlang_ffi = left_text.starts_with(':');
let full_name = target_node
.utf8_text(content)
.map_err(|_| GraphBuilderError::ParseError {
span: span_from_node(*target_node),
reason: "failed to read module-qualified call".to_string(),
})?
.to_string();
Ok((full_name, is_erlang_ffi))
} else {
Ok((String::new(), false))
}
}
_ => {
if let Ok(text) = target_node.utf8_text(content) {
Ok((text.to_string(), false))
} else {
Ok((String::new(), false))
}
}
}
}
fn span_from_node(node: Node<'_>) -> Span {
Span::from_bytes(node.start_byte(), node.end_byte())
}
fn is_erlang_load_nif(node: &Node, content: &[u8]) -> bool {
let Some(target) = node.child_by_field_name("target") else {
return false;
};
if target.kind() != "dot" {
return false;
}
let Some(left) = target.child_by_field_name("left") else {
return false;
};
if left.kind() != "atom" {
return false;
}
let Ok(left_text) = left.utf8_text(content) else {
return false;
};
if left_text != ":erlang" {
return false;
}
let Some(right) = target.child_by_field_name("right") else {
return false;
};
let Ok(right_text) = right.utf8_text(content) else {
return false;
};
right_text == "load_nif"
}
fn build_nif_ffi_edge(
node: Node,
_content: &[u8],
ast_graph: &ASTGraph,
helper: &mut GraphBuildHelper,
) {
use sqry_core::graph::unified::edge::kind::FfiConvention;
let caller_name = if let Some(ctx) = ast_graph.get_callable_context(node.id()) {
ctx.qualified_name.clone()
} else {
"<module>".to_string()
};
let caller_id = helper.add_function(&caller_name, None, false, false);
let ffi_func_name = "ffi::erlang::load_nif";
let span = span_from_node(node);
let ffi_func_id = helper.add_function(ffi_func_name, Some(span), false, false);
helper.add_ffi_edge(caller_id, ffi_func_id, FfiConvention::C);
}
#[derive(Debug)]
struct ASTGraph {
contexts: Vec<CallContext>,
node_to_context: HashMap<usize, usize>,
}
impl ASTGraph {
fn from_tree(tree: &Tree, content: &[u8], _max_depth: usize) -> Result<Self, String> {
let mut contexts = Vec::new();
let mut node_to_context = HashMap::new();
let query = tree_sitter::Query::new(
&tree_sitter_elixir_sqry::language(),
r#"
(call
target: (identifier) @def_keyword
(arguments
(call
target: (identifier) @function_name
) @function_call
)
(#match? @def_keyword "^(def[p]?|defmacro[p]?)$")
) @function_node
(call
target: (identifier) @def_keyword
(arguments
(identifier) @function_name_simple
)
(#match? @def_keyword "^(def[p]?|defmacro[p]?)$")
) @function_node_simple
"#,
)
.map_err(|e| format!("Failed to create query: {e}"))?;
let mut cursor = tree_sitter::QueryCursor::new();
let root = tree.root_node();
let capture_names = query.capture_names();
let mut matches = cursor.matches(&query, root, content);
while let Some(m) = matches.next() {
let mut def_keyword = None;
let mut function_name = None;
let mut function_node = None;
for capture in m.captures {
let capture_name = capture_names[capture.index as usize];
match capture_name {
"def_keyword" => def_keyword = Some(capture.node),
"function_name" | "function_name_simple" => function_name = Some(capture.node),
"function_node" | "function_node_simple" => function_node = Some(capture.node),
_ => {}
}
}
if let (Some(def_kw), Some(name_node), Some(func_node)) =
(def_keyword, function_name, function_node)
{
let name = name_node
.utf8_text(content)
.map_err(|e| format!("Failed to extract function name: {e}"))?
.to_string();
let def_keyword_text = def_kw.utf8_text(content).unwrap_or("");
let is_private = matches!(def_keyword_text, "defp" | "defmacrop");
let context_idx = contexts.len();
contexts.push(CallContext {
qualified_name: name,
span: (func_node.start_byte(), func_node.end_byte()),
is_private,
});
map_descendants_to_context(&func_node, context_idx, &mut node_to_context);
}
}
Ok(Self {
contexts,
node_to_context,
})
}
#[allow(dead_code)] fn contexts(&self) -> &[CallContext] {
&self.contexts
}
fn get_callable_context(&self, node_id: usize) -> Option<&CallContext> {
self.node_to_context
.get(&node_id)
.and_then(|idx| self.contexts.get(*idx))
}
}
fn map_descendants_to_context(node: &Node, context_idx: usize, map: &mut HashMap<usize, usize>) {
map.insert(node.id(), context_idx);
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
map_descendants_to_context(&child, context_idx, map);
}
}
#[derive(Debug, Clone)]
struct CallContext {
qualified_name: String,
#[allow(dead_code)] span: (usize, usize),
#[allow(dead_code)] is_private: bool,
}
impl CallContext {
#[allow(dead_code)] fn qualified_name(&self) -> String {
self.qualified_name.clone()
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use sqry_core::graph::unified::NodeId;
use sqry_core::graph::unified::StringId;
use sqry_core::graph::unified::build::StagingOp;
use sqry_core::graph::unified::build::test_helpers::*;
use sqry_core::graph::unified::edge::EdgeKind as UnifiedEdgeKind;
fn extract_import_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
staging
.operations()
.iter()
.filter_map(|op| {
if let StagingOp::AddEdge { kind, .. } = op
&& matches!(kind, UnifiedEdgeKind::Imports { .. })
{
return Some(kind);
}
None
})
.collect()
}
fn build_string_map(staging: &StagingGraph) -> HashMap<StringId, String> {
staging
.operations()
.iter()
.filter_map(|op| {
if let StagingOp::InternString { local_id, value } = op {
Some((*local_id, value.clone()))
} else {
None
}
})
.collect()
}
fn resolve_alias(
alias: Option<&StringId>,
string_map: &HashMap<StringId, String>,
) -> Option<String> {
alias.as_ref().and_then(|id| string_map.get(id).cloned())
}
fn parse_elixir(source: &str) -> (Tree, Vec<u8>) {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter_elixir_sqry::language())
.expect("Failed to load Elixir grammar");
let content = source.as_bytes().to_vec();
let tree = parser.parse(&content, None).expect("Failed to parse");
(tree, content)
}
fn print_tree_debug(node: tree_sitter::Node, source: &[u8], depth: usize) {
let indent = " ".repeat(depth);
let text = node.utf8_text(source).unwrap_or("<invalid>");
let text_preview = if text.len() > 30 {
format!("{}...", &text[..30])
} else {
text.to_string()
};
eprintln!("{}{}: {:?}", indent, node.kind(), text_preview);
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
print_tree_debug(child, source, depth + 1);
}
}
#[test]
#[ignore = "Debug-only test for AST visualization"]
fn test_debug_ast_elixir() {
let source = r"alias Phoenix.Controller, as: Ctrl";
let (tree, content) = parse_elixir(source);
eprintln!("\n=== AST for 'alias Phoenix.Controller, as: Ctrl' ===");
print_tree_debug(tree.root_node(), &content, 0);
let source2 = r"alias Phoenix.{Socket, Channel}";
let (tree2, content2) = parse_elixir(source2);
eprintln!("\n=== AST for 'alias Phoenix.{{Socket, Channel}}' ===");
print_tree_debug(tree2.root_node(), &content2, 0);
}
#[test]
fn test_extract_public_function() {
let source = r"
def calculate(x, y) do
x + y
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
assert_has_node(&staging, "calculate");
}
#[test]
fn test_extract_private_function() {
let source = r"
defp internal_helper(data) do
process(data)
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
assert_has_node(&staging, "internal_helper");
}
#[test]
fn test_extract_simple_call() {
let source = r"
def main(x) do
helper(x)
end
def helper(y) do
y
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let calls = collect_call_edges(&staging);
assert!(!calls.is_empty(), "Expected at least one call edge");
}
#[test]
fn test_extract_erlang_ffi_call() {
let source = r"
def hash_password(password) do
:crypto.hash(:sha256, password)
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let calls = collect_call_edges(&staging);
assert!(!calls.is_empty(), "Expected call edge for Erlang FFI call");
}
#[test]
fn test_module_qualified_call() {
let source = r#"
def render_page(conn) do
Phoenix.Controller.render(conn, "page.html")
end
"#;
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let calls = collect_call_edges(&staging);
assert!(!calls.is_empty(), "Expected module-qualified call edge");
}
#[test]
fn test_pipe_operator_chain() {
let source = r"
def process_data(data) do
data
|> Enum.map(&transform/1)
|> Enum.filter(&valid?/1)
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let calls = collect_call_edges(&staging);
assert!(!calls.is_empty(), "Expected pipe operator call edges");
}
#[test]
fn test_argument_count_two_args() {
let source = r"
def two(a, b) do
helper(a, b)
end
def helper(a, b) do
a + b
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let calls = collect_call_edges(&staging);
assert!(!calls.is_empty(), "Expected call edge to helper");
}
#[test]
fn test_import_edge_simple() {
let source = r"
defmodule MyModule do
import Enum
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let import_edges = extract_import_edges(&staging);
assert!(
!import_edges.is_empty(),
"Expected at least one import edge"
);
let edge = import_edges[0];
if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
assert!(
*is_wildcard,
"Simple import should be wildcard (imports all)"
);
} else {
panic!("Expected Imports edge kind");
}
}
#[test]
fn test_import_edge_with_only() {
let source = r"
defmodule MyModule do
import List, only: [first: 1, last: 1]
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let import_edges = extract_import_edges(&staging);
assert!(
!import_edges.is_empty(),
"Expected import edge with only clause"
);
let edge = import_edges[0];
if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
assert!(
!*is_wildcard,
"Import with only: clause should NOT be wildcard"
);
} else {
panic!("Expected Imports edge kind");
}
}
#[test]
fn test_alias_edge() {
let source = r"
defmodule MyModule do
alias Phoenix.Controller
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let import_edges = extract_import_edges(&staging);
assert!(!import_edges.is_empty(), "Expected alias edge");
let string_map = build_string_map(&staging);
let edge = import_edges[0];
if let UnifiedEdgeKind::Imports { alias, is_wildcard } = edge {
assert!(
!*is_wildcard,
"Alias should NOT be wildcard (it's a reference)"
);
let alias_value = resolve_alias(alias.as_ref(), &string_map);
assert_eq!(
alias_value,
Some("Controller".to_string()),
"Default alias should be 'Controller' (last segment)"
);
} else {
panic!("Expected Imports edge kind");
}
}
#[test]
fn test_alias_with_as() {
let source = r"
defmodule MyModule do
alias Phoenix.Controller, as: Ctrl
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let import_edges = extract_import_edges(&staging);
assert!(!import_edges.is_empty(), "Expected alias edge with as");
let string_map = build_string_map(&staging);
let edge = import_edges[0];
if let UnifiedEdgeKind::Imports { alias, is_wildcard } = edge {
assert!(
!*is_wildcard,
"Alias should NOT be wildcard (it's a reference)"
);
let alias_value = resolve_alias(alias.as_ref(), &string_map);
assert_eq!(
alias_value,
Some("Ctrl".to_string()),
"Explicit alias should be 'Ctrl'"
);
} else {
panic!("Expected Imports edge kind");
}
}
#[test]
fn test_multi_alias_expansion() {
let source = r"
defmodule MyModule do
alias Phoenix.{Socket, Channel}
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let import_edges = extract_import_edges(&staging);
assert_eq!(
import_edges.len(),
2,
"Multi-alias should emit one edge per alias element"
);
let string_map = build_string_map(&staging);
let mut alias_values: Vec<String> = import_edges
.iter()
.filter_map(|edge| {
if let UnifiedEdgeKind::Imports { alias, is_wildcard } = edge {
assert!(!*is_wildcard, "Multi-alias elements should NOT be wildcard");
resolve_alias(alias.as_ref(), &string_map)
} else {
None
}
})
.collect();
alias_values.sort();
assert_eq!(
alias_values,
vec!["Channel".to_string(), "Socket".to_string()],
"Multi-alias should expand to individual aliases"
);
}
#[test]
fn test_use_edge() {
let source = r"
defmodule MyModule do
use GenServer
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let import_edges = extract_import_edges(&staging);
assert!(!import_edges.is_empty(), "Expected use edge");
let edge = import_edges[0];
if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
assert!(*is_wildcard, "use statement should be wildcard");
} else {
panic!("Expected Imports edge kind");
}
}
#[test]
fn test_require_edge() {
let source = r"
defmodule MyModule do
require Logger
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let import_edges = extract_import_edges(&staging);
assert!(!import_edges.is_empty(), "Expected require edge");
let edge = import_edges[0];
if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
assert!(
!*is_wildcard,
"require statement should NOT be wildcard (only makes macros available)"
);
} else {
panic!("Expected Imports edge kind");
}
}
#[test]
fn test_multiple_imports() {
let source = r"
defmodule MyModule do
import Enum
import List
alias Phoenix.Controller
use GenServer
require Logger
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let import_edges = extract_import_edges(&staging);
assert_eq!(
import_edges.len(),
5,
"Expected 5 import edges (import Enum, import List, alias, use, require)"
);
for edge in &import_edges {
assert!(
matches!(edge, UnifiedEdgeKind::Imports { .. }),
"All edges should be Imports"
);
}
}
fn extract_export_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
staging
.operations()
.iter()
.filter_map(|op| {
if let StagingOp::AddEdge { kind, .. } = op
&& matches!(kind, UnifiedEdgeKind::Exports { .. })
{
return Some(kind);
}
None
})
.collect()
}
#[test]
fn test_export_public_function() {
let source = r"
defmodule Visibility do
def public_fun do
:ok
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let export_edges = extract_export_edges(&staging);
assert_eq!(
export_edges.len(),
1,
"Expected one export edge for public function"
);
let edge = export_edges[0];
if let UnifiedEdgeKind::Exports { kind, alias } = edge {
assert_eq!(
*kind,
ExportKind::Direct,
"Public function export should be ExportKind::Direct"
);
assert!(
alias.is_none(),
"Public function export should not have alias"
);
} else {
panic!("Expected Exports edge kind");
}
}
#[test]
fn test_export_multiple_public_functions() {
let source = r"
defmodule MyModule do
def function_one do
:ok
end
def function_two do
:ok
end
def function_three(x) do
x * 2
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let export_edges = extract_export_edges(&staging);
assert_eq!(
export_edges.len(),
3,
"Expected three export edges for three public functions"
);
for edge in export_edges {
if let UnifiedEdgeKind::Exports { kind, alias } = edge {
assert_eq!(*kind, ExportKind::Direct);
assert!(alias.is_none());
} else {
panic!("Expected Exports edge kind");
}
}
}
#[test]
fn test_no_export_for_private_function() {
let source = r"
defmodule Secret do
defp private_fun do
:secret
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let export_edges = extract_export_edges(&staging);
assert_eq!(
export_edges.len(),
0,
"Expected no export edges for private function"
);
}
#[test]
fn test_export_mixed_public_private() {
let source = r"
defmodule Mixed do
def public_one, do: :ok
defp private_one, do: :secret
def public_two, do: :ok
defp private_two, do: :secret
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let export_edges = extract_export_edges(&staging);
assert_eq!(
export_edges.len(),
2,
"Expected two export edges for two public functions (defp should not be exported)"
);
for edge in export_edges {
if let UnifiedEdgeKind::Exports { kind, alias } = edge {
assert_eq!(*kind, ExportKind::Direct);
assert!(alias.is_none());
} else {
panic!("Expected Exports edge kind");
}
}
}
#[test]
fn test_export_public_macro() {
let source = r"
defmodule Macros do
defmacro public_macro do
quote do: :ok
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let export_edges = extract_export_edges(&staging);
assert_eq!(
export_edges.len(),
1,
"Expected one export edge for public macro"
);
let edge = export_edges[0];
if let UnifiedEdgeKind::Exports { kind, alias } = edge {
assert_eq!(
*kind,
ExportKind::Direct,
"Public macro export should be ExportKind::Direct"
);
assert!(alias.is_none(), "Public macro export should not have alias");
} else {
panic!("Expected Exports edge kind");
}
}
#[test]
fn test_no_export_for_private_macro() {
let source = r"
defmodule SecretMacros do
defmacrop private_macro do
quote do: :secret
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let export_edges = extract_export_edges(&staging);
assert_eq!(
export_edges.len(),
0,
"Expected no export edges for private macro"
);
}
#[test]
fn test_export_mixed_functions_and_macros() {
let source = r"
defmodule MixedTypes do
def public_fun, do: :ok
defp private_fun, do: :secret
defmacro public_macro, do: quote(do: :ok)
defmacrop private_macro, do: quote(do: :secret)
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let export_edges = extract_export_edges(&staging);
assert_eq!(
export_edges.len(),
2,
"Expected two export edges (one public function, one public macro)"
);
for edge in export_edges {
if let UnifiedEdgeKind::Exports { kind, alias } = edge {
assert_eq!(*kind, ExportKind::Direct);
assert!(alias.is_none());
} else {
panic!("Expected Exports edge kind");
}
}
}
fn extract_ffi_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
staging
.operations()
.iter()
.filter_map(|op| {
if let StagingOp::AddEdge { kind, .. } = op
&& matches!(kind, UnifiedEdgeKind::FfiCall { .. })
{
return Some(kind);
}
None
})
.collect()
}
#[test]
fn test_nif_basic_loading() {
let source = r"
defmodule MyNif do
@on_load :load_nifs
def load_nifs do
:erlang.load_nif('./priv/my_nif', 0)
end
def native_function(_arg), do: :erlang.nif_error(:not_loaded)
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(ffi_edges.len(), 1, "Expected one FFI edge");
if let UnifiedEdgeKind::FfiCall { convention } = ffi_edges[0] {
assert_eq!(
*convention,
sqry_core::graph::unified::edge::kind::FfiConvention::C,
"NIF calls should use C convention"
);
} else {
panic!("Expected FfiCall edge");
}
}
#[test]
fn test_nif_inline_call() {
let source = r"
defmodule SimpleNif do
def init do
:erlang.load_nif('./lib', 0)
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(ffi_edges.len(), 1, "Expected one FFI edge for inline call");
}
#[test]
fn test_nif_without_on_load() {
let source = r"
defmodule NoOnLoad do
def init do
:erlang.load_nif('./nif_lib', 0)
end
def compute(_x), do: :erlang.nif_error(:not_loaded)
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(
ffi_edges.len(),
1,
"Should detect NIF loading without @on_load"
);
}
#[test]
fn test_nif_without_stubs() {
let source = r"
defmodule NoStubs do
@on_load :init
def init do
:erlang.load_nif('./minimal', 0)
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(
ffi_edges.len(),
1,
"Should detect NIF loading without stub functions"
);
}
#[test]
fn test_nif_minimal() {
let source = r"
defmodule Minimal do
def go do
:erlang.load_nif('./x', 0)
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(ffi_edges.len(), 1, "Minimal NIF loading should be detected");
}
#[test]
fn test_nif_multiple_calls() {
let source = r"
defmodule MultiNif do
def load_crypto do
:erlang.load_nif('./crypto_nif', 0)
end
def load_math do
:erlang.load_nif('./math_nif', 0)
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(
ffi_edges.len(),
2,
"Should detect multiple NIF loading calls"
);
}
#[test]
fn test_nif_string_path() {
let source = r#"
defmodule StringPath do
def init do
:erlang.load_nif("./my_lib", 0)
end
end
"#;
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(
ffi_edges.len(),
1,
"Should detect NIF with string path (double quotes)"
);
}
#[test]
fn test_nif_charlist_path() {
let source = r"
defmodule CharlistPath do
def init do
:erlang.load_nif('./path', [])
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(
ffi_edges.len(),
1,
"Should detect NIF with charlist path (single quotes)"
);
}
#[test]
fn test_nif_variable_init_args() {
let source = r"
defmodule VariableArgs do
def init(args) do
:erlang.load_nif('./lib', args)
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(
ffi_edges.len(),
1,
"Should detect NIF with variable init args"
);
}
#[test]
fn test_nif_private_function() {
let source = r"
defmodule PrivateLoader do
defp load_nif do
:erlang.load_nif('./private', 0)
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(
ffi_edges.len(),
1,
"Should detect NIF in private function (defp)"
);
}
#[test]
fn test_nif_public_function() {
let source = r"
defmodule PublicLoader do
def load_nif do
:erlang.load_nif('./public', 0)
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(
ffi_edges.len(),
1,
"Should detect NIF in public function (def)"
);
}
#[test]
fn test_nif_nested_module() {
let source = r"
defmodule Outer do
defmodule Inner do
def init do
:erlang.load_nif('./inner_nif', 0)
end
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(ffi_edges.len(), 1, "Should detect NIF in nested module");
}
#[test]
fn test_nif_convention_is_c() {
let source = r"
defmodule ConventionTest do
def init do
:erlang.load_nif('./lib', 0)
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert!(!ffi_edges.is_empty(), "Expected at least one FFI edge");
for edge in ffi_edges {
if let UnifiedEdgeKind::FfiCall { convention } = edge {
assert_eq!(
*convention,
sqry_core::graph::unified::edge::kind::FfiConvention::C,
"All NIF edges should use C convention"
);
}
}
}
#[test]
fn test_nif_edge_count() {
let source = r"
defmodule EdgeCount do
def one do
:erlang.load_nif('./one', 0)
end
def two do
:erlang.load_nif('./two', 0)
end
def three do
:erlang.load_nif('./three', 0)
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(
ffi_edges.len(),
3,
"Should create exactly one edge per load_nif call"
);
}
#[test]
#[allow(clippy::similar_names)] fn test_nif_edge_endpoints() {
let source = r"
defmodule NifModule do
def load_nif do
:erlang.load_nif('./mylib', 0)
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(ffi_edges.len(), 1, "Expected exactly one FfiCall edge");
if let UnifiedEdgeKind::FfiCall { convention } = ffi_edges[0] {
assert_eq!(
*convention,
sqry_core::graph::unified::edge::kind::FfiConvention::C,
"NIF calls should use C convention"
);
} else {
panic!("Expected FfiCall edge");
}
let mut caller_node_id: Option<NodeId> = None;
#[allow(clippy::similar_names)] let mut callee_node_id: Option<NodeId> = None;
for op in staging.operations() {
if let StagingOp::AddNode { entry, expected_id } = op {
let canonical_name = staging
.resolve_node_canonical_name(entry)
.expect("Node name should resolve");
if canonical_name == "load_nif"
&& matches!(entry.kind, sqry_core::graph::unified::NodeKind::Function)
{
caller_node_id = *expected_id;
}
if canonical_name == "ffi::erlang::load_nif" {
callee_node_id = *expected_id;
}
}
}
assert!(
caller_node_id.is_some(),
"Expected to find caller node named 'load_nif'"
);
assert!(
callee_node_id.is_some(),
"Expected to find callee node named 'ffi::erlang::load_nif'"
);
let caller_id = caller_node_id.unwrap();
let callee_id = callee_node_id.unwrap();
let has_correct_edge = staging.operations().iter().any(|op| {
if let StagingOp::AddEdge {
source,
target,
kind,
..
} = op
{
matches!(kind, UnifiedEdgeKind::FfiCall { .. })
&& *source == caller_id
&& *target == callee_id
} else {
false
}
});
assert!(
has_correct_edge,
"Expected FfiCall edge connecting NifModule::load_nif to ffi::erlang::load_nif"
);
}
#[test]
fn test_no_ffi_regular_erlang_call() {
let source = r"
defmodule MyModule do
def process(list) do
:lists.map(fn x -> x * 2 end, list)
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(
ffi_edges.len(),
0,
"Should not detect regular Erlang calls as FFI"
);
}
#[test]
fn test_no_ffi_comment() {
let source = r"
defmodule CommentTest do
# :erlang.load_nif('./commented', 0)
def init do
:ok
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(ffi_edges.len(), 0, "Should not detect load_nif in comments");
}
#[test]
fn test_no_ffi_string_literal() {
let source = r#"
defmodule StringTest do
def message do
"Call :erlang.load_nif to load"
end
end
"#;
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(
ffi_edges.len(),
0,
"Should not detect load_nif in string literals"
);
}
#[test]
fn test_no_ffi_similar_name() {
let source = r"
defmodule SimilarName do
def init do
:erlang.load_nif_module('./lib', 0)
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(
ffi_edges.len(),
0,
"Should not detect similar function names (load_nif_module)"
);
}
#[test]
fn test_no_ffi_wrong_module() {
let source = r"
defmodule WrongModule do
def init do
:other.load_nif('./lib', 0)
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(
ffi_edges.len(),
0,
"Should not detect load_nif from modules other than :erlang"
);
}
#[test]
fn test_nif_malformed_incomplete_args() {
let source = r"
defmodule Malformed do
def init do
:erlang.load_nif()
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let _ffi_edges = extract_ffi_edges(&staging);
}
#[test]
fn test_nif_empty_arguments() {
let source = r"
defmodule EmptyArgs do
def init do
:erlang.load_nif('./lib')
end
end
";
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert!(
ffi_edges.len() <= 1,
"Should handle NIF calls with non-standard arity gracefully"
);
}
#[test]
fn test_nif_complex_path() {
let source = r#"
defmodule ComplexPath do
def init(base_path) do
:erlang.load_nif(base_path <> "/nif", 0)
end
end
"#;
let (tree, content) = parse_elixir(source);
let mut staging = StagingGraph::new();
let builder = ElixirGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.ex"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(
ffi_edges.len(),
1,
"Should detect NIF with complex/interpolated paths"
);
}
}