use std::{collections::HashMap, path::Path};
use sqry_core::graph::unified::StagingGraph;
use sqry_core::graph::unified::build::GraphBuildHelper;
use sqry_core::graph::unified::edge::FfiConvention;
use sqry_core::graph::unified::edge::kind::TypeOfContext;
use sqry_core::graph::unified::node::NodeId;
use sqry_core::graph::{GraphBuilder, GraphBuilderError, GraphResult, Language, Span};
use tree_sitter::{Node, Tree};
#[cfg(test)]
use sqry_core::graph::unified::storage::NodeEntry;
use crate::preprocess::preprocess_content;
use crate::relations::type_extractor::extract_type_names_from_haskell_type;
#[derive(Debug, Clone)]
struct FfiDeclaration {
wrapper_name: String,
foreign_symbol: String,
convention: FfiConvention,
safety: FfiSafety,
span: (usize, usize),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FfiSafety {
Unsafe,
Safe,
Default, }
type FfiRegistry = HashMap<String, FfiDeclaration>;
#[derive(Debug, Clone, Copy)]
pub struct HaskellGraphBuilder {
max_scope_depth: usize,
}
impl Default for HaskellGraphBuilder {
fn default() -> Self {
Self {
max_scope_depth: 3, }
}
}
impl HaskellGraphBuilder {
#[must_use]
pub fn new(max_scope_depth: usize) -> Self {
Self { max_scope_depth }
}
}
impl GraphBuilder for HaskellGraphBuilder {
fn build_graph(
&self,
tree: &Tree,
content: &[u8],
file: &Path,
staging: &mut StagingGraph,
) -> GraphResult<()> {
let processed = preprocess_content(content);
let content = processed.as_ref();
let mut helper = GraphBuildHelper::new(staging, file, Language::Haskell);
let module_name = extract_module_name(tree, content);
let module_node_name = module_name.as_deref().unwrap_or("<module>");
let module_id = helper.add_module(module_node_name, None);
let module_exports = extract_module_exports(tree, content);
let ast_graph = ASTGraph::from_tree(tree, content, self.max_scope_depth).map_err(|e| {
GraphBuilderError::ParseError {
span: Span::default(),
reason: e,
}
})?;
let mut context_to_node: HashMap<String, NodeId> = HashMap::new();
for context in ast_graph.contexts() {
let qualified_name = if let Some(module) = &module_name {
format!("{}.{}", module, context.qualified_name)
} else {
context.qualified_name.clone()
};
let visibility = match &module_exports {
None => "public", Some(exports) => {
if exports.contains(&context.qualified_name) {
"public"
} else {
"private"
}
}
};
let span = Some(Span::from_bytes(context.span.0, context.span.1));
let node_id = helper.add_function_with_visibility(
&qualified_name,
span,
false,
false,
Some(visibility),
);
context_to_node.insert(context.qualified_name.clone(), node_id);
}
match &module_exports {
None => {
for (context_name, &node_id) in &context_to_node {
if !context_name.contains('.') {
helper.add_export_edge(module_id, node_id);
}
}
}
Some(exports) => {
for export_name in exports {
if let Some(&node_id) = context_to_node.get(export_name) {
helper.add_export_edge(module_id, node_id);
}
}
}
}
extract_import_edges(tree.root_node(), content, module_id, &mut helper);
let mut ffi_registry = FfiRegistry::new();
collect_ffi_declarations(tree.root_node(), content, &mut ffi_registry);
build_ffi_edges(
&ffi_registry,
module_name.as_deref(),
module_id,
&mut helper,
);
extract_typeof_edges(
tree.root_node(),
content,
&context_to_node,
module_name.as_deref(),
&mut helper,
);
visit_node_for_calls(
tree.root_node(),
content,
&ast_graph,
&mut helper,
&context_to_node,
module_name.as_ref(),
);
Ok(())
}
fn language(&self) -> Language {
Language::Haskell
}
}
fn extract_module_name(tree: &Tree, content: &[u8]) -> Option<String> {
let root = tree.root_node();
let mut cursor = root.walk();
for child in root.children(&mut cursor) {
if child.kind() == "header"
&& let Some(name) = extract_module_name_from_header(child, content)
{
return Some(name);
}
}
None
}
fn extract_module_exports(tree: &Tree, content: &[u8]) -> Option<Vec<String>> {
let root = tree.root_node();
let mut cursor = root.walk();
for child in root.children(&mut cursor) {
if child.kind() == "header" {
return extract_exports_from_header(child, content);
}
}
None
}
fn extract_exports_from_header(header: Node<'_>, content: &[u8]) -> Option<Vec<String>> {
let mut header_cursor = header.walk();
for header_child in header.children(&mut header_cursor) {
if header_child.kind() == "exports" {
let mut exports = Vec::new();
let mut exports_cursor = header_child.walk();
for export_node in header_child.children(&mut exports_cursor) {
if let Ok(text) = export_node.utf8_text(content) {
let trimmed = text.trim().trim_matches(&['(', ')', ','][..]);
if !trimmed.is_empty() && trimmed != "exports" {
exports.push(trimmed.to_string());
}
}
let mut inner_cursor = export_node.walk();
for inner_child in export_node.children(&mut inner_cursor) {
if matches!(
inner_child.kind(),
"variable" | "type" | "constructor" | "operator"
) && let Ok(inner_text) = inner_child.utf8_text(content)
{
let trimmed = inner_text.trim();
if !trimmed.is_empty() && !exports.contains(&trimmed.to_string()) {
exports.push(trimmed.to_string());
}
}
}
}
return Some(exports);
}
}
None
}
fn extract_module_name_from_header(header: Node<'_>, content: &[u8]) -> Option<String> {
let mut header_cursor = header.walk();
for header_child in header.children(&mut header_cursor) {
if matches!(header_child.kind(), "module" | "module_id")
&& let Ok(text) = header_child.utf8_text(content)
&& text != "module"
{
return Some(text.to_string());
}
}
header
.utf8_text(content)
.ok()
.and_then(parse_module_name_from_text)
}
fn parse_module_name_from_text(text: &str) -> Option<String> {
let mut tokens = text.split_whitespace();
while let Some(token) = tokens.next() {
if token == "module"
&& let Some(name_token) = tokens.next()
{
let trimmed = name_token.trim_end_matches(['(', ';']);
if !trimmed.is_empty() {
return Some(trimmed.to_string());
}
}
}
None
}
#[allow(
clippy::match_same_arms,
reason = "ccall and capi are distinct patterns that map to same convention for documentation clarity"
)]
fn parse_calling_convention(node: Node, content: &[u8]) -> Option<FfiConvention> {
let text = node.utf8_text(content).ok()?;
match text {
"ccall" => Some(FfiConvention::C),
"stdcall" => Some(FfiConvention::Stdcall),
"capi" => Some(FfiConvention::C),
_ => None,
}
}
fn extract_string_literal(node: Node, content: &[u8]) -> Option<String> {
if node.kind() == "string" || node.kind() == "string_literal" {
let text = node.utf8_text(content).ok()?;
Some(text.trim_matches('"').to_string())
} else {
None
}
}
fn parse_foreign_import(node: Node, content: &[u8]) -> Option<FfiDeclaration> {
let convention = {
let mut cursor = node.walk();
let mut convention_opt = None;
for child in node.children(&mut cursor) {
if let Some(conv) = parse_calling_convention(child, content) {
convention_opt = Some(conv);
break;
}
}
convention_opt?
};
let safety = {
let mut cursor = node.walk();
let mut safety_opt = FfiSafety::Default;
for child in node.children(&mut cursor) {
if child.kind() == "safety"
&& let Ok(text) = child.utf8_text(content)
{
match text {
"unsafe" => {
safety_opt = FfiSafety::Unsafe;
break;
}
"safe" => {
safety_opt = FfiSafety::Safe;
break;
}
_ => {}
}
}
else if child.kind() == "unsafe" {
safety_opt = FfiSafety::Unsafe;
break;
} else if child.kind() == "safe" {
safety_opt = FfiSafety::Safe;
break;
}
}
safety_opt
};
let foreign_symbol = {
let mut cursor = node.walk();
let mut symbol_opt = None;
for child in node.children(&mut cursor) {
if child.kind() == "entity" {
let mut entity_cursor = child.walk();
for entity_child in child.children(&mut entity_cursor) {
if entity_child.kind() == "string" || entity_child.kind() == "string_literal" {
symbol_opt = extract_string_literal(entity_child, content);
break;
}
}
if symbol_opt.is_some() {
break;
}
}
if child.kind() == "string" || child.kind() == "string_literal" {
symbol_opt = extract_string_literal(child, content);
break;
}
}
symbol_opt?
};
let wrapper_name = {
let mut cursor = node.walk();
let mut name_opt = None;
for child in node.children(&mut cursor) {
if child.kind() == "signature" {
let mut sig_cursor = child.walk();
for sig_child in child.children(&mut sig_cursor) {
if matches!(sig_child.kind(), "variable" | "identifier" | "name")
&& let Ok(text) = sig_child.utf8_text(content)
{
name_opt = Some(text.to_string());
break;
}
}
if name_opt.is_some() {
break;
}
}
if matches!(child.kind(), "variable" | "identifier" | "name")
&& let Ok(text) = child.utf8_text(content)
{
name_opt = Some(text.to_string());
break;
}
}
name_opt?
};
let span = (node.start_byte(), node.end_byte());
Some(FfiDeclaration {
wrapper_name,
foreign_symbol,
convention,
safety,
span,
})
}
fn collect_ffi_declarations(node: Node, content: &[u8], ffi_registry: &mut FfiRegistry) {
if node.kind() == "foreign_import"
&& let Some(decl) = parse_foreign_import(node, content)
{
ffi_registry.insert(decl.wrapper_name.clone(), decl);
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
collect_ffi_declarations(child, content, ffi_registry);
}
}
fn build_ffi_edges(
ffi_registry: &FfiRegistry,
module_name: Option<&str>,
module_id: NodeId,
helper: &mut GraphBuildHelper,
) {
for (wrapper_name, decl) in ffi_registry {
let qualified_name = if let Some(module) = module_name {
format!("{module}.{wrapper_name}")
} else {
wrapper_name.clone()
};
let span = Some(Span::from_bytes(decl.span.0, decl.span.1));
let is_unsafe = matches!(decl.safety, FfiSafety::Unsafe);
let wrapper_node = helper.add_function(
&qualified_name,
span,
false, is_unsafe, );
let convention_str = match decl.convention {
FfiConvention::C => "C",
FfiConvention::Stdcall => "stdcall",
_ => "unknown",
};
let ffi_target_name = format!("ffi::{convention_str}::{}", decl.foreign_symbol);
let ffi_target_node = helper.ensure_function(&ffi_target_name, None, false, false);
helper.add_ffi_edge(wrapper_node, ffi_target_node, decl.convention);
helper.add_contains_edge(module_id, wrapper_node);
}
}
fn extract_import_edges(
root: Node<'_>,
content: &[u8],
module_id: NodeId,
helper: &mut GraphBuildHelper,
) {
let mut cursor = root.walk();
for child in root.children(&mut cursor) {
if child.kind() == "imports" {
let mut imports_cursor = child.walk();
for import_node in child.children(&mut imports_cursor) {
if import_node.kind() == "import" {
build_import_edge(import_node, content, module_id, helper);
}
}
} else if child.kind() == "import" {
build_import_edge(child, content, module_id, helper);
}
}
}
fn build_import_edge(
import_node: Node<'_>,
content: &[u8],
module_id: NodeId,
helper: &mut GraphBuildHelper,
) {
let mut module_name: Option<String> = None;
let mut alias_name: Option<String> = None;
let mut is_qualified = false;
let mut has_hiding = false;
let mut has_explicit_list = false;
let mut seen_as = false;
let mut cursor = import_node.walk();
for child in import_node.children(&mut cursor) {
match child.kind() {
"qualified" => {
is_qualified = true;
}
"as" => {
seen_as = true;
}
"module" | "module_id" => {
if let Ok(text) = child.utf8_text(content)
&& text != "import"
&& text != "qualified"
&& text != "as"
&& text != "hiding"
{
if seen_as {
alias_name = Some(text.to_string());
} else if module_name.is_none() {
module_name = Some(text.to_string());
}
}
}
"module_alias" | "alias" => {
if let Ok(text) = child.utf8_text(content) {
alias_name = Some(text.to_string());
}
}
"import_list" | "exports" | "explicit_list" => {
has_explicit_list = true;
}
"hidden_list" | "hiding" => {
has_hiding = true;
}
_ => {
if module_name.is_none() {
let mut inner_cursor = child.walk();
for inner_child in child.children(&mut inner_cursor) {
if (inner_child.kind() == "module" || inner_child.kind() == "module_id")
&& let Ok(text) = inner_child.utf8_text(content)
{
module_name = Some(text.to_string());
break;
}
}
}
}
}
}
if module_name.is_none()
&& let Ok(import_text) = import_node.utf8_text(content)
{
let parts: Vec<&str> = import_text.split_whitespace().collect();
for (i, part) in parts.iter().enumerate() {
if *part == "import" || *part == "qualified" || *part == "as" || *part == "hiding" {
continue;
}
if i > 0 && parts.get(i - 1) == Some(&"as") {
if alias_name.is_none() {
alias_name = Some((*part).to_string());
}
continue;
}
if module_name.is_none() && !part.starts_with('(') {
module_name = Some((*part).to_string());
}
}
}
if let Some(imported_module) = module_name {
let span = Span::from_bytes(import_node.start_byte(), import_node.end_byte());
let import_name = if is_qualified {
format!("qualified:{imported_module}")
} else {
imported_module.clone()
};
let import_id = helper.add_import(&import_name, Some(span));
let is_wildcard = !has_explicit_list && !has_hiding && !is_qualified;
helper.add_import_edge_full(module_id, import_id, alias_name.as_deref(), is_wildcard);
}
}
fn extract_apply(app_node: Node<'_>, content: &[u8]) -> (String, usize) {
let mut arg_count = 0;
let mut current = app_node;
let mut function_name = String::new();
loop {
if current.child_by_field_name("argument").is_some() {
arg_count += 1;
}
if let Some(function) = current.child_by_field_name("function") {
if function.kind() == "apply" {
current = function;
continue;
}
if function_name.is_empty() {
function_name = strip_backticks(function.utf8_text(content).unwrap_or(""));
}
break;
}
let mut cursor = current.walk();
let mut found_nested_apply = false;
for child in current.children(&mut cursor) {
match child.kind() {
"apply" => {
current = child;
found_nested_apply = true;
arg_count += 1;
break;
}
"variable" | "constructor" | "qualified_variable" | "qualified_constructor" => {
if function_name.is_empty() {
function_name = strip_backticks(child.utf8_text(content).unwrap_or(""));
} else {
arg_count += 1;
}
}
"literal" | "parens" => {
arg_count += 1;
}
_ => {
if !function_name.is_empty() {
arg_count += 1;
}
}
}
}
if !found_nested_apply {
break;
}
}
(function_name, arg_count)
}
fn extract_infix(app_node: Node<'_>, content: &[u8]) -> (String, usize) {
let mut cursor = app_node.walk();
let mut operator_name = String::new();
for child in app_node.children(&mut cursor) {
match child.kind() {
"operator" | "variable_operator" | "constructor_operator" => {
operator_name = child.utf8_text(content).unwrap_or("").to_string();
break;
}
"variable" | "qualified_variable" | "constructor" | "qualified_constructor" => {
operator_name = strip_backticks(child.utf8_text(content).unwrap_or(""));
break;
}
"infix_id" | "prefix_id" => {
operator_name = strip_backticks(child.utf8_text(content).unwrap_or(""));
break;
}
_ => {}
}
}
(operator_name, 2)
}
fn strip_backticks(name: &str) -> String {
if name.len() > 2 && name.starts_with('`') && name.ends_with('`') {
name[1..name.len() - 1].to_string()
} else {
name.to_string()
}
}
#[allow(dead_code)] fn span_from_node(node: Node<'_>) -> Span {
Span::from_bytes(node.start_byte(), node.end_byte())
}
fn visit_node_for_calls(
node: Node<'_>,
content: &[u8],
ast_graph: &ASTGraph,
helper: &mut GraphBuildHelper,
context_to_node: &HashMap<String, NodeId>,
module_name: Option<&String>,
) {
match node.kind() {
"apply" | "infix" | "negate" => {
if let Some(context) = ast_graph.get_callable_context(node.id())
&& let Some(&caller_id) = context_to_node.get(&context.qualified_name)
{
let (callee_name, arg_count) = match node.kind() {
"apply" => extract_apply(node, content),
"infix" => extract_infix(node, content),
"negate" => (String::from("negate"), 1),
_ => return, };
if !callee_name.is_empty() {
let qualified_callee = if callee_name.contains('.') {
callee_name.clone()
} else if let Some(module) = module_name {
format!("{module}.{callee_name}")
} else {
callee_name.clone()
};
let callee_id = helper.add_function(&qualified_callee, None, false, false);
let argument_count = u8::try_from(arg_count).unwrap_or(u8::MAX);
let call_span = span_from_node(node);
helper.add_call_edge_full_with_span(
caller_id,
callee_id,
argument_count,
false,
vec![call_span],
);
}
}
}
_ => {}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
visit_node_for_calls(
child,
content,
ast_graph,
helper,
context_to_node,
module_name,
);
}
}
fn extract_typeof_edges(
node: Node<'_>,
content: &[u8],
context_to_node: &HashMap<String, NodeId>,
module_name: Option<&str>,
helper: &mut GraphBuildHelper,
) {
match node.kind() {
"signature" => {
process_type_signature(node, content, context_to_node, module_name, helper);
}
"data_type" => {
process_data_type(node, content, module_name, helper);
}
"newtype" => {
process_newtype(node, content, module_name, helper);
}
"type_synomym" => {
process_type_synonym(node, content, module_name, helper);
}
"class" => {
process_class_declarations(node, content, context_to_node, module_name, helper);
return;
}
_ => {}
}
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
extract_typeof_edges(child, content, context_to_node, module_name, helper);
}
}
fn extract_type_text(node: Node<'_>, content: &[u8]) -> Option<String> {
match node.kind() {
"quantified_type" | "lazy_field" | "strict_field" => {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
if matches!(child.kind(), "forall" | "forall_required" | "context") {
continue;
}
return extract_type_text(child, content);
}
node.utf8_text(content).ok().map(|t| t.trim().to_string())
}
"parens" => {
if let Some(inner) = node.named_child(0) {
return extract_type_text(inner, content);
}
node.utf8_text(content).ok().map(|t| t.trim().to_string())
}
_ => node.utf8_text(content).ok().map(|t| t.trim().to_string()),
}
}
fn flatten_function_type(node: Node<'_>, content: &[u8]) -> (Vec<String>, String) {
let mut params = Vec::new();
let mut current = node;
loop {
let inner = unwrap_parens(unwrap_quantified_type(current));
if matches!(inner.kind(), "function" | "linear_function") {
if let Some(param_node) = inner.child_by_field_name("parameter")
&& let Some(param_text) = extract_type_text(param_node, content)
{
params.push(param_text);
}
if let Some(result_node) = inner.child_by_field_name("result") {
current = result_node;
continue;
}
let ret = inner
.utf8_text(content)
.map(|t| t.trim().to_string())
.unwrap_or_default();
return (params, ret);
}
let ret = extract_type_text(inner, content).unwrap_or_default();
return (params, ret);
}
}
fn unwrap_quantified_type(node: Node<'_>) -> Node<'_> {
if node.kind() == "quantified_type" {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
if !matches!(
child.kind(),
"forall" | "forall_required" | "context" | "constraints"
) {
return child;
}
}
}
node
}
fn unwrap_forall(node: Node<'_>) -> Node<'_> {
if matches!(node.kind(), "forall" | "forall_required") {
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
if child.kind() != "quantified_variables" {
return child;
}
}
}
node
}
fn unwrap_parens(node: Node<'_>) -> Node<'_> {
if node.kind() == "parens"
&& let Some(inner) = node.named_child(0)
{
return inner;
}
node
}
fn extract_signature_names<'a>(sig_node: Node<'a>, content: &'a [u8]) -> Vec<String> {
let mut names = Vec::new();
if let Some(name_node) = sig_node.child_by_field_name("name")
&& let Ok(text) = name_node.utf8_text(content)
{
let trimmed = text.trim();
if !trimmed.is_empty() {
names.push(trimmed.to_string());
}
}
if let Some(names_node) = sig_node.child_by_field_name("names") {
let mut cursor = names_node.walk();
for child in names_node.children_by_field_name("name", &mut cursor) {
if let Ok(text) = child.utf8_text(content) {
let trimmed = text.trim();
if !trimmed.is_empty() && !names.contains(&trimmed.to_string()) {
names.push(trimmed.to_string());
}
}
}
}
names
}
struct UnwrappedSignature<'a> {
actual_type_node: Node<'a>,
constraint_text: Option<String>,
constraint_node: Option<Node<'a>>,
}
fn unwrap_signature_type<'a>(
type_node: Node<'a>,
sig_node: Node<'a>,
content: &[u8],
) -> UnwrappedSignature<'a> {
let type_node = unwrap_forall(type_node);
let type_node = unwrap_parens(type_node);
let type_node = unwrap_forall(type_node);
if type_node.kind() == "context" {
let constraint_node = type_node.child_by_field_name("context");
let constraint_text = constraint_node
.and_then(|c| c.utf8_text(content).ok())
.map(|t| t.trim().to_string());
let inner = type_node.child_by_field_name("type").unwrap_or(type_node);
UnwrappedSignature {
actual_type_node: inner,
constraint_text,
constraint_node,
}
} else {
let constraint_node = sig_node.child_by_field_name("constraint");
let constraint_text = constraint_node
.and_then(|c| c.utf8_text(content).ok())
.map(|t| t.trim().to_string());
UnwrappedSignature {
actual_type_node: type_node,
constraint_text,
constraint_node,
}
}
}
fn emit_references_edges(
source_id: NodeId,
type_node: Node<'_>,
constraint_node: Option<Node<'_>>,
content: &[u8],
helper: &mut GraphBuildHelper,
) {
let mut seen = std::collections::HashSet::new();
emit_references_edges_dedup(
source_id,
type_node,
constraint_node,
content,
helper,
&mut seen,
);
}
fn emit_references_edges_dedup(
source_id: NodeId,
type_node: Node<'_>,
constraint_node: Option<Node<'_>>,
content: &[u8],
helper: &mut GraphBuildHelper,
seen: &mut std::collections::HashSet<String>,
) {
let ref_names = extract_type_names_from_haskell_type(type_node, content);
for ref_name in &ref_names {
if seen.insert(ref_name.clone()) {
let ref_type_id = helper.add_type(ref_name, None);
helper.add_reference_edge(source_id, ref_type_id);
}
}
if let Some(constraint_ast) = constraint_node {
for ref_name in extract_type_names_from_haskell_type(constraint_ast, content) {
if seen.insert(ref_name.clone()) {
let ref_type_id = helper.add_type(&ref_name, None);
helper.add_reference_edge(source_id, ref_type_id);
}
}
}
}
fn process_type_signature(
sig_node: Node<'_>,
content: &[u8],
context_to_node: &HashMap<String, NodeId>,
module_name: Option<&str>,
helper: &mut GraphBuildHelper,
) {
let func_names = extract_signature_names(sig_node, content);
if func_names.is_empty() {
return;
}
let Some(type_node) = sig_node.child_by_field_name("type") else {
return;
};
let unwrapped = unwrap_signature_type(type_node, sig_node, content);
let (param_types, return_type) = flatten_function_type(unwrapped.actual_type_node, content);
for func_name in &func_names {
let node_id = if let Some(&id) = context_to_node.get(func_name) {
id
} else if let Some(module) = module_name {
let qualified = format!("{module}.{func_name}");
if let Some(&id) = context_to_node.get(&qualified) {
id
} else {
continue;
}
} else {
continue;
};
for (idx, param_type) in param_types.iter().enumerate() {
let type_id = helper.add_type(param_type, None);
#[allow(clippy::cast_possible_truncation)]
helper.add_typeof_edge_with_context(
node_id,
type_id,
Some(TypeOfContext::Parameter),
Some(idx as u16),
None, );
}
if !return_type.is_empty() {
let return_type_id = helper.add_type(&return_type, None);
helper.add_typeof_edge_with_context(
node_id,
return_type_id,
Some(TypeOfContext::Return),
Some(0),
None,
);
}
if let Some(ref constraint) = unwrapped.constraint_text {
let constraint_type_id = helper.add_type(constraint, None);
helper.add_typeof_edge_with_context(
node_id,
constraint_type_id,
Some(TypeOfContext::Constraint),
None,
None,
);
}
emit_references_edges(
node_id,
unwrapped.actual_type_node,
unwrapped.constraint_node,
content,
helper,
);
}
}
fn process_data_type(
data_node: Node<'_>,
content: &[u8],
module_name: Option<&str>,
helper: &mut GraphBuildHelper,
) {
let Some(name_node) = data_node.child_by_field_name("name") else {
return;
};
let Some(type_name) = name_node.utf8_text(content).ok() else {
return;
};
let type_name = type_name.trim();
if type_name.is_empty() {
return;
}
let qualified_name = if let Some(module) = module_name {
format!("{module}.{type_name}")
} else {
type_name.to_string()
};
let span = Some(Span::from_bytes(
data_node.start_byte(),
data_node.end_byte(),
));
let data_type_id = helper.add_type(&qualified_name, span);
let Some(constructors_node) = data_node.child_by_field_name("constructors") else {
return;
};
let mut ref_seen = std::collections::HashSet::new();
match constructors_node.kind() {
"data_constructors" => {
process_data_constructors(
constructors_node,
content,
data_type_id,
helper,
&mut ref_seen,
);
}
"gadt_constructors" => {
process_gadt_constructors(
constructors_node,
content,
data_type_id,
helper,
&mut ref_seen,
);
}
_ => {}
}
}
fn process_data_constructors(
constructors_node: Node<'_>,
content: &[u8],
data_type_id: NodeId,
helper: &mut GraphBuildHelper,
ref_seen: &mut std::collections::HashSet<String>,
) {
let mut cursor = constructors_node.walk();
for data_ctor in constructors_node.named_children(&mut cursor) {
if data_ctor.kind() != "data_constructor" {
continue;
}
if let Some(ctor_node) = data_ctor.child_by_field_name("constructor") {
match ctor_node.kind() {
"record" => {
process_record_fields(ctor_node, content, data_type_id, helper, ref_seen);
}
"prefix" => {
process_prefix_constructor(ctor_node, content, data_type_id, helper, ref_seen);
}
"infix" => {
process_infix_constructor(ctor_node, content, data_type_id, helper, ref_seen);
}
_ => {}
}
}
}
}
fn process_record_fields(
record_node: Node<'_>,
content: &[u8],
data_type_id: NodeId,
helper: &mut GraphBuildHelper,
ref_seen: &mut std::collections::HashSet<String>,
) {
let fields_container = record_node
.child_by_field_name("fields")
.unwrap_or(record_node);
let mut field_index: u16 = 0;
let mut cursor = fields_container.walk();
for child in fields_container.named_children(&mut cursor) {
if child.kind() != "field" {
continue;
}
let field_type_node = child
.child_by_field_name("type")
.or_else(|| child.child_by_field_name("parameter"));
let Some(type_node) = field_type_node else {
continue;
};
let Some(type_text) = extract_type_text(type_node, content) else {
continue;
};
let mut name_cursor = child.walk();
let names: Vec<String> = child
.children_by_field_name("name", &mut name_cursor)
.filter_map(|n| n.utf8_text(content).ok().map(|t| t.trim().to_string()))
.filter(|n| !n.is_empty())
.collect();
if names.is_empty() {
let type_id = helper.add_type(&type_text, None);
helper.add_typeof_edge_with_context(
data_type_id,
type_id,
Some(TypeOfContext::Field),
Some(field_index),
None,
);
field_index += 1;
} else {
for name in &names {
let type_id = helper.add_type(&type_text, None);
helper.add_typeof_edge_with_context(
data_type_id,
type_id,
Some(TypeOfContext::Field),
Some(field_index),
Some(name),
);
field_index += 1;
}
}
emit_references_edges_dedup(data_type_id, type_node, None, content, helper, ref_seen);
}
}
fn process_prefix_constructor(
prefix_node: Node<'_>,
content: &[u8],
data_type_id: NodeId,
helper: &mut GraphBuildHelper,
ref_seen: &mut std::collections::HashSet<String>,
) {
let mut cursor = prefix_node.walk();
let mut param_index: u16 = 0;
for child in prefix_node.children_by_field_name("field", &mut cursor) {
if let Some(type_text) = extract_type_text(child, content) {
let type_id = helper.add_type(&type_text, None);
helper.add_typeof_edge_with_context(
data_type_id,
type_id,
Some(TypeOfContext::Parameter),
Some(param_index),
None,
);
param_index += 1;
emit_references_edges_dedup(data_type_id, child, None, content, helper, ref_seen);
}
}
}
fn process_infix_constructor(
infix_node: Node<'_>,
content: &[u8],
data_type_id: NodeId,
helper: &mut GraphBuildHelper,
ref_seen: &mut std::collections::HashSet<String>,
) {
if let Some(left) = infix_node.child_by_field_name("left_operand")
&& let Some(type_text) = extract_type_text(left, content)
{
let type_id = helper.add_type(&type_text, None);
helper.add_typeof_edge_with_context(
data_type_id,
type_id,
Some(TypeOfContext::Parameter),
Some(0),
None,
);
emit_references_edges_dedup(data_type_id, left, None, content, helper, ref_seen);
}
if let Some(right) = infix_node.child_by_field_name("right_operand")
&& let Some(type_text) = extract_type_text(right, content)
{
let type_id = helper.add_type(&type_text, None);
helper.add_typeof_edge_with_context(
data_type_id,
type_id,
Some(TypeOfContext::Parameter),
Some(1),
None,
);
emit_references_edges_dedup(data_type_id, right, None, content, helper, ref_seen);
}
}
fn process_gadt_constructors(
gadt_node: Node<'_>,
content: &[u8],
data_type_id: NodeId,
helper: &mut GraphBuildHelper,
ref_seen: &mut std::collections::HashSet<String>,
) {
let mut cursor = gadt_node.walk();
for ctor in gadt_node.named_children(&mut cursor) {
if ctor.kind() != "gadt_constructor" {
continue;
}
if let Some(type_node) = ctor.child_by_field_name("type") {
if type_node.kind() == "record" {
process_record_fields(type_node, content, data_type_id, helper, ref_seen);
} else {
let inner_type = if type_node.kind() == "prefix" {
type_node.child_by_field_name("type").unwrap_or(type_node)
} else {
type_node
};
let (params, _return_type) = flatten_function_type(inner_type, content);
for (idx, param_type) in params.iter().enumerate() {
let type_id = helper.add_type(param_type, None);
#[allow(clippy::cast_possible_truncation)]
helper.add_typeof_edge_with_context(
data_type_id,
type_id,
Some(TypeOfContext::Parameter),
Some(idx as u16),
None,
);
}
emit_references_edges_dedup(
data_type_id,
inner_type,
None,
content,
helper,
ref_seen,
);
}
}
}
}
fn process_newtype(
newtype_node: Node<'_>,
content: &[u8],
module_name: Option<&str>,
helper: &mut GraphBuildHelper,
) {
let Some(name_node) = newtype_node.child_by_field_name("name") else {
return;
};
let Some(type_name) = name_node.utf8_text(content).ok() else {
return;
};
let type_name = type_name.trim();
if type_name.is_empty() {
return;
}
let qualified_name = if let Some(module) = module_name {
format!("{module}.{type_name}")
} else {
type_name.to_string()
};
let span = Some(Span::from_bytes(
newtype_node.start_byte(),
newtype_node.end_byte(),
));
let newtype_type_id = helper.add_type(&qualified_name, span);
let Some(ctor_node) = newtype_node.child_by_field_name("constructor") else {
return;
};
if let Some(field_node) = ctor_node.child_by_field_name("field") {
if field_node.kind() == "record" {
let mut ref_seen = std::collections::HashSet::new();
process_record_fields(field_node, content, newtype_type_id, helper, &mut ref_seen);
} else {
let type_ast_node = field_node
.child_by_field_name("type")
.or_else(|| field_node.child_by_field_name("parameter"));
let field_type = type_ast_node
.and_then(|t| extract_type_text(t, content))
.or_else(|| extract_type_text(field_node, content));
let field_name = field_node
.child_by_field_name("name")
.and_then(|n| n.utf8_text(content).ok())
.map(|t| t.trim().to_string());
if let Some(type_text) = field_type {
let type_id = helper.add_type(&type_text, None);
helper.add_typeof_edge_with_context(
newtype_type_id,
type_id,
Some(TypeOfContext::Field),
Some(0),
field_name.as_deref(),
);
let ref_node = type_ast_node.unwrap_or(field_node);
emit_references_edges(newtype_type_id, ref_node, None, content, helper);
}
}
}
}
fn process_type_synonym(
syn_node: Node<'_>,
content: &[u8],
module_name: Option<&str>,
helper: &mut GraphBuildHelper,
) {
let Some(name_node) = syn_node.child_by_field_name("name") else {
return;
};
let Some(type_name) = name_node.utf8_text(content).ok() else {
return;
};
let type_name = type_name.trim();
if type_name.is_empty() {
return;
}
let qualified_name = if let Some(module) = module_name {
format!("{module}.{type_name}")
} else {
type_name.to_string()
};
let span = Some(Span::from_bytes(syn_node.start_byte(), syn_node.end_byte()));
let alias_id = helper.add_type(&qualified_name, span);
let Some(type_node) = syn_node.child_by_field_name("type") else {
return;
};
let Some(target_text) = extract_type_text(type_node, content) else {
return;
};
let target_id = helper.add_type(&target_text, None);
helper.add_typeof_edge_with_context(
alias_id,
target_id,
Some(TypeOfContext::TypeParameter),
None,
None,
);
emit_references_edges(alias_id, type_node, None, content, helper);
}
fn process_class_declarations(
class_node: Node<'_>,
content: &[u8],
context_to_node: &HashMap<String, NodeId>,
module_name: Option<&str>,
helper: &mut GraphBuildHelper,
) {
let class_name = class_node
.child_by_field_name("name")
.and_then(|n| n.utf8_text(content).ok())
.map(|t| t.trim().to_string());
let Some(class_name) = class_name else {
return;
};
let Some(decls_node) = class_node.child_by_field_name("declarations") else {
return;
};
find_and_process_class_signatures(
decls_node,
content,
&class_name,
context_to_node,
module_name,
helper,
);
}
fn find_and_process_class_signatures(
node: Node<'_>,
content: &[u8],
class_name: &str,
context_to_node: &HashMap<String, NodeId>,
module_name: Option<&str>,
helper: &mut GraphBuildHelper,
) {
if node.kind() == "signature" {
process_class_method_signature(
node,
content,
class_name,
context_to_node,
module_name,
helper,
);
return;
}
let mut cursor = node.walk();
for child in node.named_children(&mut cursor) {
find_and_process_class_signatures(
child,
content,
class_name,
context_to_node,
module_name,
helper,
);
}
}
fn process_class_method_signature(
sig_node: Node<'_>,
content: &[u8],
class_name: &str,
context_to_node: &HashMap<String, NodeId>,
module_name: Option<&str>,
helper: &mut GraphBuildHelper,
) {
let method_names = extract_signature_names(sig_node, content);
if method_names.is_empty() {
return;
}
let Some(type_node) = sig_node.child_by_field_name("type") else {
return;
};
let unwrapped = unwrap_signature_type(type_node, sig_node, content);
let (param_types, return_type) = flatten_function_type(unwrapped.actual_type_node, content);
for method_name in &method_names {
let qualified_method = if let Some(module) = module_name {
format!("{module}.{class_name}.{method_name}")
} else {
format!("{class_name}.{method_name}")
};
let method_id = if let Some(&id) = context_to_node.get(&qualified_method) {
id
} else {
let span = Some(Span::from_bytes(sig_node.start_byte(), sig_node.end_byte()));
helper.add_function(&qualified_method, span, false, false)
};
for (idx, param_type) in param_types.iter().enumerate() {
let type_id = helper.add_type(param_type, None);
#[allow(clippy::cast_possible_truncation)]
helper.add_typeof_edge_with_context(
method_id,
type_id,
Some(TypeOfContext::Parameter),
Some(idx as u16),
None,
);
}
if !return_type.is_empty() {
let return_type_id = helper.add_type(&return_type, None);
helper.add_typeof_edge_with_context(
method_id,
return_type_id,
Some(TypeOfContext::Return),
Some(0),
None,
);
}
if let Some(ref constraint) = unwrapped.constraint_text {
let constraint_type_id = helper.add_type(constraint, None);
helper.add_typeof_edge_with_context(
method_id,
constraint_type_id,
Some(TypeOfContext::Constraint),
None,
None,
);
}
emit_references_edges(
method_id,
unwrapped.actual_type_node,
unwrapped.constraint_node,
content,
helper,
);
}
}
#[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 recursion_limits = sqry_core::config::RecursionLimits::load_or_default()
.map_err(|e| format!("Failed to load recursion limits: {e}"))?;
let file_ops_depth = recursion_limits
.effective_file_ops_depth()
.map_err(|e| format!("Invalid file_ops_depth configuration: {e}"))?;
let mut guard = sqry_core::query::security::RecursionGuard::new(file_ops_depth)
.map_err(|e| format!("Failed to create recursion guard: {e}"))?;
let root = tree.root_node();
extract_functions_recursive(
root,
content,
&mut contexts,
&mut node_to_context,
&mut guard,
)?;
Ok(Self {
contexts,
node_to_context,
})
}
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 extract_functions_recursive(
node: Node<'_>,
content: &[u8],
contexts: &mut Vec<CallContext>,
node_to_context: &mut HashMap<usize, usize>,
guard: &mut sqry_core::query::security::RecursionGuard,
) -> Result<(), String> {
guard
.enter()
.map_err(|e| format!("Recursion limit exceeded: {e}"))?;
match node.kind() {
"function" => {
if let Some(name) = extract_function_name_from_function(node, content) {
let context_idx = contexts.len();
contexts.push(CallContext {
qualified_name: name,
span: (node.start_byte(), node.end_byte()),
});
map_descendants_to_context(&node, context_idx, node_to_context);
}
}
"bind" => {
if let Some(name) = extract_function_name_from_bind(node, content) {
let context_idx = contexts.len();
contexts.push(CallContext {
qualified_name: name,
span: (node.start_byte(), node.end_byte()),
});
map_descendants_to_context(&node, context_idx, node_to_context);
}
}
_ => {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
extract_functions_recursive(child, content, contexts, node_to_context, guard)?;
}
}
}
guard.exit();
Ok(())
}
fn extract_function_name_from_function(node: Node<'_>, content: &[u8]) -> Option<String> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "variable"
&& let Ok(name) = child.utf8_text(content)
{
return Some(name.to_string());
}
}
None
}
fn extract_function_name_from_bind(node: Node<'_>, content: &[u8]) -> Option<String> {
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
if child.kind() == "variable"
&& let Ok(name) = child.utf8_text(content)
{
return Some(name.to_string());
}
}
None
}
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,
span: (usize, usize),
}
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::edge::EdgeKind as UnifiedEdgeKind;
use sqry_core::graph::unified::node::NodeKind;
use std::path::Path;
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.and_then(|id| string_map.get(id).cloned())
}
fn build_node_lookup(staging: &StagingGraph) -> HashMap<NodeId, (String, NodeKind)> {
let mut nodes = HashMap::new();
for op in staging.operations() {
if let StagingOp::AddNode {
entry,
expected_id: Some(node_id),
} = op
&& let Some(name) = staging.resolve_node_display_name(Language::Haskell, entry)
{
nodes.insert(*node_id, (name, entry.kind));
}
}
nodes
}
fn build_node_canonical_lookup(staging: &StagingGraph) -> HashMap<NodeId, (String, NodeKind)> {
let mut nodes = HashMap::new();
for op in staging.operations() {
if let StagingOp::AddNode {
entry,
expected_id: Some(node_id),
} = op
&& let Some(name) = staging.resolve_node_canonical_name(entry)
{
nodes.insert(*node_id, (name.to_string(), entry.kind));
}
}
nodes
}
fn has_node(staging: &StagingGraph, name: &str, kind: NodeKind) -> bool {
let nodes = build_node_lookup(staging);
nodes
.values()
.any(|(node_name, node_kind)| node_name == name && *node_kind == kind)
}
fn has_call_edge(
staging: &StagingGraph,
caller: Option<&str>,
callee: &str,
arg_count: Option<u8>,
) -> bool {
let nodes = build_node_lookup(staging);
for op in staging.operations() {
if let StagingOp::AddEdge {
source,
target,
kind,
..
} = op
{
let UnifiedEdgeKind::Calls { argument_count, .. } = kind else {
continue;
};
if let Some(expected) = arg_count
&& *argument_count != expected
{
continue;
}
let source_name = nodes.get(source).map(|(name, _)| name.as_str());
let target_name = nodes.get(target).map(|(name, _)| name.as_str());
if target_name != Some(callee) {
continue;
}
if let Some(expected_caller) = caller {
if source_name == Some(expected_caller) {
return true;
}
} else {
return true;
}
}
}
false
}
fn parse_haskell(source: &str) -> (Tree, Vec<u8>) {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter_haskell::LANGUAGE.into())
.expect("Failed to load Haskell grammar");
let content = source.as_bytes().to_vec();
let tree = parser.parse(&content, None).expect("Failed to parse");
(tree, content)
}
fn print_tree(node: Node, source: &[u8], depth: usize) {
let indent = " ".repeat(depth);
let text = node.utf8_text(source).unwrap_or("<invalid>");
let text_preview = if text.len() > 50 {
format!("{}...", &text[..50])
} else {
text.to_string()
};
eprintln!("{}{} {:?}", indent, node.kind(), text_preview);
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
print_tree(child, source, depth + 1);
}
}
#[test]
#[ignore = "Debug-only test for AST visualization - use in development only"]
fn test_debug_ast() {
let source = r"
import qualified Data.Map as M
";
let (tree, content) = parse_haskell(source);
eprintln!("\n=== AST Structure ===");
print_tree(tree.root_node(), &content, 0);
}
#[test]
fn test_extract_top_level_function() {
let source = r"
calculate :: Int -> Int -> Int
calculate x y = x + y
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_node(&staging, "calculate", NodeKind::Function),
"Expected to find 'calculate' function"
);
}
#[test]
fn test_function_application() {
let source = r"
add :: Int -> Int -> Int
add x y = x + y
main :: IO ()
main = print (add 10 20)
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_call_edge(&staging, Some("main"), "add", None),
"Expected call edge from main to add"
);
}
#[test]
fn test_qualified_call() {
let source = r"
import qualified Data.Text as T
process :: String -> Text
process input = T.pack input
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
let nodes = build_node_lookup(&staging);
let has_pack_call = staging.operations().iter().any(|op| {
if let StagingOp::AddEdge { target, kind, .. } = op {
if !matches!(kind, UnifiedEdgeKind::Calls { .. }) {
return false;
}
let target_name = nodes.get(target).map(|(name, _)| name.as_str());
return target_name.is_some_and(|name| name.contains("pack"));
}
false
});
assert!(has_pack_call, "Expected call edge to qualified pack");
}
#[test]
fn test_operator_application() {
let source = r"
sum :: Int -> Int -> Int
sum a b = a + b
difference :: Int -> Int -> Int
difference a b = (-) a b
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
staging.operations().iter().any(|op| {
if let StagingOp::AddEdge { kind, .. } = op {
return matches!(
kind,
UnifiedEdgeKind::Calls {
argument_count: 2,
..
}
);
}
false
}),
"Expected binary operator call"
);
}
#[test]
fn test_argument_counting() {
let source = r"
calculate :: Int -> Int -> Int -> Int
calculate x y z = x + y + z
main :: IO ()
main = print (calculate 1 2 3)
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_call_edge(&staging, None, "calculate", Some(3)),
"Expected call with 3 arguments to calculate"
);
}
#[test]
fn test_zero_argument_call() {
let source = r"
getValue :: Int
getValue = 42
main :: IO ()
main = print getValue
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_node(&staging, "getValue", NodeKind::Function),
"Expected to find 'getValue' function"
);
}
#[test]
fn test_module_header_creates_module_node() {
let source = r#"
module Demo.Module where
main :: IO ()
main = print "ok"
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("Main.hs"), &mut staging)
.unwrap();
assert!(
has_node(&staging, "Demo.Module", NodeKind::Module),
"Expected module node with name 'Demo.Module'"
);
}
#[test]
fn test_where_clause_local_function() {
let source = r"
process :: Int -> Int
process x = helper x * 2
where
helper y = y + 1
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_node(&staging, "process", NodeKind::Function),
"Expected to find 'process' function"
);
}
#[test]
fn test_backtick_operator() {
let source = r"
divide :: Int -> Int -> Int
divide x y = x `div` y
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
staging.operations().iter().any(|op| matches!(
op,
StagingOp::AddEdge {
kind: UnifiedEdgeKind::Calls { .. },
..
}
)),
"Expected to find operator call edges"
);
}
#[test]
fn test_partial_application_section() {
let source = r"
addOne :: Int -> Int
addOne = (+ 1)
mulTwo :: Int -> Int
mulTwo = (2 *)
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_node(&staging, "addOne", NodeKind::Function),
"Expected to find 'addOne' function"
);
assert!(
has_node(&staging, "mulTwo", NodeKind::Function),
"Expected to find 'mulTwo' function"
);
}
#[test]
fn test_let_binding_local_function() {
let source = r"
compute :: Int -> Int
compute x =
let helper y = y * 2
in helper x + 1
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_node(&staging, "compute", NodeKind::Function),
"Expected to find 'compute' function"
);
}
#[test]
fn test_qualified_operator() {
let source = r"
import qualified Data.List as L
combine :: [Int] -> [Int] -> [Int]
combine xs ys = xs L.++ ys
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_node(&staging, "combine", NodeKind::Function),
"Expected to find 'combine' function"
);
}
#[test]
fn test_import_edge_simple() {
let source = r"
import Data.List
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &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 { alias, is_wildcard } = edge {
assert!(*is_wildcard, "Simple import should be wildcard");
assert!(alias.is_none(), "Simple import should not have alias");
} else {
panic!("Expected Imports edge kind");
}
}
#[test]
fn test_import_edge_qualified() {
let source = r"
import qualified Data.Map as M
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
let import_edges = extract_import_edges(&staging);
assert!(!import_edges.is_empty(), "Expected qualified import edge");
let string_map = build_string_map(&staging);
let edge = import_edges[0];
if let UnifiedEdgeKind::Imports { alias, is_wildcard } = edge {
assert!(
!*is_wildcard,
"Qualified import should NOT be wildcard (requires qualifier)"
);
let alias_value = resolve_alias(alias.as_ref(), &string_map);
assert_eq!(
alias_value,
Some("M".to_string()),
"Qualified import alias should be 'M'"
);
} else {
panic!("Expected Imports edge kind");
}
}
#[test]
fn test_import_edge_with_list() {
let source = r"
import Data.Text (pack, unpack)
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
let import_edges = extract_import_edges(&staging);
assert!(
!import_edges.is_empty(),
"Expected import edge with explicit list"
);
let edge = import_edges[0];
if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
assert!(
!*is_wildcard,
"Import with explicit list should NOT be wildcard"
);
} else {
panic!("Expected Imports edge kind");
}
}
#[test]
fn test_import_edge_hiding() {
let source = r"
import Data.Maybe hiding (fromJust)
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
let import_edges = extract_import_edges(&staging);
assert!(!import_edges.is_empty(), "Expected import edge with hiding");
let edge = import_edges[0];
if let UnifiedEdgeKind::Imports { is_wildcard, .. } = edge {
assert!(!*is_wildcard, "Import with hiding should NOT be wildcard");
} else {
panic!("Expected Imports edge kind");
}
}
#[test]
fn test_multiple_imports() {
let source = r"
module Test where
import Data.List
import qualified Data.Map as M
import Data.Text (pack, unpack)
import Data.Maybe hiding (fromJust)
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
let import_edges = extract_import_edges(&staging);
assert_eq!(import_edges.len(), 4, "Expected 4 import edges");
for edge in &import_edges {
assert!(
matches!(edge, UnifiedEdgeKind::Imports { .. }),
"All edges should be Imports"
);
}
}
fn extract_ffi_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
staging
.operations()
.iter()
.filter_map(|op| {
if let StagingOp::AddEdge { kind, .. } = op
&& matches!(kind, UnifiedEdgeKind::FfiCall { .. })
{
Some(kind)
} else {
None
}
})
.collect()
}
fn get_node_metadata(staging: &StagingGraph, name_pattern: &str) -> Option<NodeEntry> {
for op in staging.operations() {
if let StagingOp::AddNode { entry, .. } = op
&& staging
.resolve_node_display_name(Language::Haskell, entry)
.is_some_and(|name| name.contains(name_pattern))
{
return Some(entry.clone());
}
}
None
}
fn has_ffi_edge(
staging: &StagingGraph,
convention: FfiConvention,
target_symbol: &str,
) -> bool {
let node_lookup = build_node_canonical_lookup(staging);
let convention_str = match convention {
FfiConvention::C => "C",
FfiConvention::Stdcall => "stdcall",
_ => panic!("Unsupported FFI convention in has_ffi_edge: {convention:?}"),
};
let ffi_prefix = format!("ffi::{convention_str}::");
staging.operations().iter().any(|op| {
if let StagingOp::AddEdge { target, kind, .. } = op
&& let UnifiedEdgeKind::FfiCall {
convention: edge_conv,
} = kind
{
*edge_conv == convention
&& node_lookup.get(target).is_some_and(|(name, _)| {
let Some(foreign_symbol) = name.strip_prefix(&ffi_prefix) else {
return false;
};
let stripped_symbol =
foreign_symbol.strip_prefix('&').unwrap_or(foreign_symbol);
let normalized_symbol = stripped_symbol
.split_ascii_whitespace()
.next_back()
.unwrap_or(stripped_symbol);
normalized_symbol == target_symbol
})
} else {
false
}
})
}
#[test]
fn test_ffi_ccall_static() {
let source = r#"
module FFI where
import Foreign.C.Types
foreign import ccall "exp" c_exp :: Double -> Double
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert!(!ffi_edges.is_empty(), "Expected FFI edge");
assert!(
has_ffi_edge(&staging, FfiConvention::C, "exp"),
"Expected FfiCall edge to 'exp' with C convention"
);
}
#[test]
fn test_ffi_ccall_dynamic() {
let source = r#"
module FFI where
import Foreign.Ptr
foreign import ccall "dynamic" mkFun :: FunPtr (Int -> IO ()) -> (Int -> IO ())
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_ffi_edge(&staging, FfiConvention::C, "dynamic"),
"Expected FfiCall edge to 'dynamic' with C convention"
);
}
#[test]
fn test_ffi_ccall_wrapper() {
let source = r#"
module FFI where
import Foreign.Ptr
foreign import ccall "wrapper" createCB :: (Int -> IO ()) -> IO (FunPtr (Int -> IO ()))
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_ffi_edge(&staging, FfiConvention::C, "wrapper"),
"Expected FfiCall edge to 'wrapper' with C convention"
);
}
#[test]
fn test_ffi_ccall_address_of() {
let source = r#"
module FFI where
import Foreign.Ptr
foreign import ccall "&errno" errno_ptr :: Ptr Int
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_ffi_edge(&staging, FfiConvention::C, "errno"),
"Expected FfiCall edge to '&errno' with C convention"
);
}
#[test]
fn test_ffi_stdcall() {
let source = r#"
module FFI where
foreign import stdcall "MessageBoxA" msgBox :: Int -> IO Int
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_ffi_edge(&staging, FfiConvention::Stdcall, "MessageBoxA"),
"Expected FfiCall edge to 'MessageBoxA' with Stdcall convention"
);
}
#[test]
fn test_ffi_capi() {
let source = r#"
module FFI where
foreign import capi "stdio.h printf" my_printf :: IO Int
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_ffi_edge(&staging, FfiConvention::C, "printf"),
"Expected FfiCall edge to 'stdio.h printf' with C convention (CAPI maps to C)"
);
}
#[test]
fn test_ffi_unsafe_modifier() {
let source = r#"
module FFI where
foreign import ccall unsafe "fast" fast :: Int -> Int
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_ffi_edge(&staging, FfiConvention::C, "fast"),
"Expected FfiCall edge for unsafe FFI"
);
let node =
get_node_metadata(&staging, "FFI.fast").expect("Expected wrapper node 'FFI.fast'");
assert!(
node.is_unsafe,
"Expected is_unsafe=true for unsafe FFI wrapper, but got is_unsafe=false"
);
}
#[test]
fn test_ffi_safe_modifier() {
let source = r#"
module FFI where
foreign import ccall safe "blocking" blocking :: Int -> IO Int
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_ffi_edge(&staging, FfiConvention::C, "blocking"),
"Expected FfiCall edge for safe FFI"
);
let node = get_node_metadata(&staging, "FFI.blocking").expect("Expected wrapper node");
assert!(
!node.is_unsafe,
"Expected is_unsafe=false for safe FFI wrapper"
);
}
#[test]
fn test_ffi_multiple_declarations() {
let source = r#"
module FFI where
foreign import ccall "exp" c_exp :: Double -> Double
foreign import ccall "log" c_log :: Double -> Double
foreign import stdcall "Win" win :: Int -> IO Int
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(ffi_edges.len(), 3, "Expected 3 FFI edges");
assert!(has_ffi_edge(&staging, FfiConvention::C, "exp"));
assert!(has_ffi_edge(&staging, FfiConvention::C, "log"));
assert!(has_ffi_edge(&staging, FfiConvention::Stdcall, "Win"));
}
#[test]
fn test_ffi_complex_types() {
let source = r#"
module FFI where
import Foreign.Ptr
import Foreign.C.Types
foreign import ccall "complex" cfunc :: Ptr CInt -> CSize -> IO (Ptr CDouble)
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_ffi_edge(&staging, FfiConvention::C, "complex"),
"Expected FfiCall edge despite complex types"
);
}
#[test]
fn test_ffi_mixed_conventions() {
let source = r#"
module FFI where
foreign import ccall "printf" c_printf :: IO ()
foreign import stdcall "WinAPI" winapi :: IO ()
foreign import capi "math.h sin" capi_sin :: Double -> Double
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(ffi_edges.len(), 3, "Expected 3 FFI edges");
assert!(has_ffi_edge(&staging, FfiConvention::C, "printf"));
assert!(has_ffi_edge(&staging, FfiConvention::Stdcall, "WinAPI"));
assert!(has_ffi_edge(&staging, FfiConvention::C, "sin"));
}
#[test]
fn test_no_ffi_regular_function() {
let source = r#"
module NoFFI where
regularFunc :: Int -> Int
regularFunc x = x + 1
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(ffi_edges.len(), 0, "Expected NO FFI edges for regular code");
}
#[test]
fn test_no_ffi_comment() {
let source = r#"
module NoFFI where
-- foreign import ccall "fake" fake :: Int -> Int
regularFunc :: Int -> Int
regularFunc x = x
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(
ffi_edges.len(),
0,
"Expected NO FFI edges when foreign is in comment"
);
}
#[test]
fn test_no_ffi_string_literal() {
let source = r#"
module NoFFI where
message :: String
message = "foreign import ccall"
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(
ffi_edges.len(),
0,
"Expected NO FFI edges when foreign is in string"
);
}
#[test]
fn test_ffi_empty_file() {
let source = "";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(ffi_edges.len(), 0, "Expected NO FFI edges in empty file");
}
#[test]
fn test_ffi_multiline_signature() {
let source = r#"
module FFI where
foreign import ccall "complex_func" complexFunc ::
Int ->
Double ->
IO Int
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_ffi_edge(&staging, FfiConvention::C, "complex_func"),
"Expected FfiCall edge despite multiline signature"
);
}
#[test]
fn test_ffi_with_module_name() {
let source = r#"
module Math.FFI where
foreign import ccall "sin" c_sin :: Double -> Double
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
let has_qualified = staging.operations().iter().any(|op| {
if let StagingOp::AddNode { entry, .. } = op {
staging
.resolve_node_display_name(Language::Haskell, entry)
.is_some_and(|name| name == "Math.FFI.c_sin")
} else {
false
}
});
assert!(has_qualified, "Expected qualified wrapper function name");
assert!(has_ffi_edge(&staging, FfiConvention::C, "sin"));
}
#[test]
#[should_panic(expected = "Unsupported FFI convention in has_ffi_edge")]
fn test_has_ffi_edge_panics_on_unsupported_convention() {
let staging = StagingGraph::new();
let _ = has_ffi_edge(&staging, FfiConvention::Cdecl, "printf");
}
#[test]
fn test_ffi_with_funptr() {
let source = r#"
module FFI where
import Foreign.Ptr
foreign import ccall "signal" c_signal :: Int -> FunPtr (Int -> IO ()) -> IO (FunPtr (Int -> IO ()))
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_ffi_edge(&staging, FfiConvention::C, "signal"),
"Expected FfiCall edge with FunPtr types"
);
}
#[test]
fn test_ffi_with_cstring() {
let source = r#"
module FFI where
import Foreign.C.String
foreign import ccall "strlen" c_strlen :: CString -> IO Int
foreign import ccall "strcpy" c_strcpy :: CString -> CString -> IO CString
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
let ffi_edges = extract_ffi_edges(&staging);
assert_eq!(ffi_edges.len(), 2, "Expected 2 FFI edges with CString");
assert!(has_ffi_edge(&staging, FfiConvention::C, "strlen"));
assert!(has_ffi_edge(&staging, FfiConvention::C, "strcpy"));
}
#[test]
fn test_ffi_unsafe_persists_in_code_graph() {
use sqry_core::graph::unified::concurrent::CodeGraph;
let source = r#"
module FFI where
foreign import ccall unsafe "fast_sqrt" fast_sqrt :: Double -> Double
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
let mut graph = CodeGraph::new();
let file_id = graph
.files_mut()
.register_with_language(Path::new("test.hs"), Some(builder.language()))
.expect("Failed to register file");
staging.apply_file_id(file_id);
let string_remap = staging
.commit_strings(graph.strings_mut())
.expect("Failed to commit strings");
staging
.apply_string_remap(&string_remap)
.expect("Failed to apply string remap");
let node_id_mapping = staging
.commit_nodes(graph.nodes_mut())
.expect("Failed to commit nodes");
let index_entries: Vec<_> = node_id_mapping
.values()
.filter_map(|&actual_id| {
graph.nodes().get(actual_id).map(|entry| {
(
actual_id,
entry.kind,
entry.name,
entry.qualified_name,
entry.file,
)
})
})
.collect();
for (node_id, kind, name, qualified_name, file) in index_entries {
graph
.indices_mut()
.add(node_id, kind, name, qualified_name, file);
}
let wrapper_name_str = "fast_sqrt";
let mut found_unsafe_node = false;
for (node_id, entry) in graph.nodes().iter() {
if let Some(name) = graph.strings().resolve(entry.name)
&& name.contains(wrapper_name_str)
&& entry.is_unsafe
{
found_unsafe_node = true;
eprintln!(
"Found unsafe FFI wrapper node: id={:?} name={} is_unsafe={}",
node_id, name, entry.is_unsafe
);
break;
}
}
assert!(
found_unsafe_node,
"Expected to find wrapper node with is_unsafe=true in committed CodeGraph"
);
}
use sqry_core::graph::unified::edge::kind::TypeOfContext;
fn has_typeof_edge(
staging: &StagingGraph,
source_name: Option<&str>,
target_type: &str,
context: Option<TypeOfContext>,
index: Option<u16>,
name: Option<&str>,
) -> bool {
let nodes = build_node_lookup(staging);
let string_map = build_string_map(staging);
for op in staging.operations() {
if let StagingOp::AddEdge {
source,
target,
kind,
..
} = op
{
let UnifiedEdgeKind::TypeOf {
context: edge_ctx,
index: edge_idx,
name: edge_name,
} = kind
else {
continue;
};
if *edge_ctx != context {
continue;
}
if *edge_idx != index {
continue;
}
let resolved_name = edge_name.and_then(|id| string_map.get(&id).cloned());
let expected_name = name.map(String::from);
if resolved_name != expected_name {
continue;
}
let target_name = nodes.get(target).map(|(n, _)| n.as_str());
if !target_name.is_some_and(|n| n.contains(target_type)) {
continue;
}
if let Some(expected_source) = source_name {
let src_name = nodes.get(source).map(|(n, _)| n.as_str());
if !src_name.is_some_and(|n| n.contains(expected_source)) {
continue;
}
}
return true;
}
}
false
}
fn has_typeof_edge_exact(
staging: &StagingGraph,
source_name: &str,
target_type: &str,
context: Option<TypeOfContext>,
index: Option<u16>,
name: Option<&str>,
) -> bool {
let nodes = build_node_lookup(staging);
let string_map = build_string_map(staging);
for op in staging.operations() {
if let StagingOp::AddEdge {
source,
target,
kind,
..
} = op
{
let UnifiedEdgeKind::TypeOf {
context: edge_ctx,
index: edge_idx,
name: edge_name,
} = kind
else {
continue;
};
if *edge_ctx != context {
continue;
}
if *edge_idx != index {
continue;
}
let resolved_name = edge_name.and_then(|id| string_map.get(&id).cloned());
let expected_name = name.map(String::from);
if resolved_name != expected_name {
continue;
}
let target_name = nodes.get(target).map(|(n, _)| n.as_str());
if !target_name.is_some_and(|n| n.contains(target_type)) {
continue;
}
let src_name = nodes.get(source).map(|(n, _)| n.as_str());
if src_name.is_some_and(|n| n == source_name) {
return true;
}
}
}
false
}
fn has_typeof_edge_full_exact(
staging: &StagingGraph,
source_name: &str,
target_type: &str,
context: Option<TypeOfContext>,
index: Option<u16>,
name: Option<&str>,
) -> bool {
let nodes = build_node_lookup(staging);
let string_map = build_string_map(staging);
for op in staging.operations() {
if let StagingOp::AddEdge {
source,
target,
kind,
..
} = op
{
let UnifiedEdgeKind::TypeOf {
context: edge_ctx,
index: edge_idx,
name: edge_name,
} = kind
else {
continue;
};
if *edge_ctx != context {
continue;
}
if *edge_idx != index {
continue;
}
let resolved_name = edge_name.and_then(|id| string_map.get(&id).cloned());
let expected_name = name.map(String::from);
if resolved_name != expected_name {
continue;
}
let target_name = nodes.get(target).map(|(n, _)| n.as_str());
if target_name.is_none_or(|n| n != target_type) {
continue;
}
let src_name = nodes.get(source).map(|(n, _)| n.as_str());
if src_name.is_some_and(|n| n == source_name) {
return true;
}
}
}
false
}
fn count_typeof_edges(staging: &StagingGraph) -> usize {
staging
.operations()
.iter()
.filter(|op| {
matches!(
op,
StagingOp::AddEdge {
kind: UnifiedEdgeKind::TypeOf { .. },
..
}
)
})
.count()
}
#[test]
fn test_typeof_simple_signature() {
let source = r"
foo :: Int -> Int
foo x = x + 1
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("foo"),
"Int",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(Int, idx=0) for foo"
);
assert!(
has_typeof_edge(
&staging,
Some("foo"),
"Int",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(Int, idx=0) for foo"
);
}
#[test]
fn test_typeof_multi_param() {
let source = r"
calc :: Int -> String -> Bool
calc x y = True
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("calc"),
"Int",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(Int, idx=0)"
);
assert!(
has_typeof_edge(
&staging,
Some("calc"),
"String",
Some(TypeOfContext::Parameter),
Some(1),
None
),
"Expected Parameter(String, idx=1)"
);
assert!(
has_typeof_edge(
&staging,
Some("calc"),
"Bool",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(Bool, idx=0)"
);
}
#[test]
fn test_typeof_no_params() {
let source = r"
value :: Int
value = 42
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("value"),
"Int",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(Int) for value"
);
assert!(
!has_typeof_edge(
&staging,
Some("value"),
"Int",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Should NOT have Parameter edge for non-function signature"
);
}
#[test]
fn test_typeof_data_record_fields() {
let source = r"
data Rec = Rec { name :: String, age :: Int }
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("Rec"),
"String",
Some(TypeOfContext::Field),
Some(0),
Some("name")
),
"Expected Field(String, idx=0, name='name')"
);
assert!(
has_typeof_edge(
&staging,
Some("Rec"),
"Int",
Some(TypeOfContext::Field),
Some(1),
Some("age")
),
"Expected Field(Int, idx=1, name='age')"
);
}
#[test]
fn test_typeof_data_prefix_constructor() {
let source = r"
data Wrapper = Wrap Int String
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("Wrapper"),
"Int",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(Int, idx=0) for prefix constructor"
);
assert!(
has_typeof_edge(
&staging,
Some("Wrapper"),
"String",
Some(TypeOfContext::Parameter),
Some(1),
None
),
"Expected Parameter(String, idx=1) for prefix constructor"
);
}
#[test]
fn test_typeof_newtype() {
let source = r"
newtype Wrapped = Wrapped Int
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("Wrapped"),
"Int",
Some(TypeOfContext::Field),
Some(0),
None
),
"Expected Field(Int, idx=0) for newtype"
);
}
#[test]
fn test_typeof_type_synonym() {
let source = r"
type Alias = Int
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("Alias"),
"Int",
Some(TypeOfContext::TypeParameter),
None,
None
),
"Expected TypeParameter(Int) for type synonym"
);
}
#[test]
fn test_typeof_class_method() {
let source = r"
class Run a where
run :: a -> Int
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("run"),
"a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(a, idx=0) for class method"
);
assert!(
has_typeof_edge(
&staging,
Some("run"),
"Int",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(Int) for class method"
);
}
#[test]
fn test_typeof_complex_types() {
let source = r"
process :: IO String -> Maybe Int
process x = Nothing
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("process"),
"IO String",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter('IO String') for complex type"
);
assert!(
has_typeof_edge(
&staging,
Some("process"),
"Maybe Int",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return('Maybe Int') for complex type"
);
}
#[test]
fn test_typeof_qualified_module() {
let source = r"
module Demo where
demo :: Int -> Int
demo x = x
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("Demo.demo"),
"Int",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter edge for module-qualified function"
);
}
#[test]
fn test_typeof_no_edges_without_signature() {
let source = r"
bar = 42
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
let typeof_count = count_typeof_edges(&staging);
assert_eq!(
typeof_count, 0,
"Expected no TypeOf edges for function without signature"
);
}
#[test]
fn test_typeof_multi_name_signature() {
let source = r"
foo, bar :: Int -> Int
foo x = x + 1
bar x = x - 1
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("foo"),
"Int",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(Int) for foo in multi-name signature"
);
assert!(
has_typeof_edge(
&staging,
Some("bar"),
"Int",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(Int) for bar in multi-name signature"
);
}
#[test]
fn test_typeof_constraint() {
let source = r"
showIt :: Show a => a -> String
showIt x = show x
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("showIt"),
"Show a",
Some(TypeOfContext::Constraint),
None,
None
),
"Expected Constraint(Show a) for constrained signature"
);
assert!(
has_typeof_edge(
&staging,
Some("showIt"),
"a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(a) for constrained signature"
);
assert!(
has_typeof_edge(
&staging,
Some("showIt"),
"String",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(String) for constrained signature"
);
}
#[test]
fn test_typeof_multi_field_name() {
let source = r"
data Point = Point { x, y :: Int }
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("Point"),
"Int",
Some(TypeOfContext::Field),
Some(0),
Some("x")
),
"Expected Field(Int, name='x') for multi-field-name record"
);
assert!(
has_typeof_edge(
&staging,
Some("Point"),
"Int",
Some(TypeOfContext::Field),
Some(1),
Some("y")
),
"Expected Field(Int, name='y') for multi-field-name record"
);
}
#[test]
fn test_typeof_infix_constructor() {
let source = r"
data IntPair = Int :+: Int
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("IntPair"),
"Int",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(Int, idx=0) for left infix operand"
);
assert!(
has_typeof_edge(
&staging,
Some("IntPair"),
"Int",
Some(TypeOfContext::Parameter),
Some(1),
None
),
"Expected Parameter(Int, idx=1) for right infix operand"
);
}
#[test]
fn test_typeof_linear_function() {
let source = r"
{-# LANGUAGE LinearTypes #-}
linear :: a %1 -> b -> b
linear _ y = y
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("linear"),
"a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(a, idx=0) for linear function"
);
assert!(
has_typeof_edge(
&staging,
Some("linear"),
"b",
Some(TypeOfContext::Parameter),
Some(1),
None
),
"Expected Parameter(b, idx=1) for linear function"
);
assert!(
has_typeof_edge(
&staging,
Some("linear"),
"b",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(b, idx=0) for linear function"
);
}
#[test]
fn test_typeof_gadt_constructor() {
let source = r"
{-# LANGUAGE GADTs #-}
data Expr a where
Lit :: Int -> Expr Int
Add :: Expr Int -> Expr Int -> Expr Int
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("Expr"),
"Int",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(Int, idx=0) from Lit constructor"
);
assert!(
has_typeof_edge(
&staging,
Some("Expr"),
"Expr Int",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(Expr Int, idx=0) from Add constructor"
);
assert!(
has_typeof_edge(
&staging,
Some("Expr"),
"Expr Int",
Some(TypeOfContext::Parameter),
Some(1),
None
),
"Expected Parameter(Expr Int, idx=1) from Add constructor"
);
let typeof_count = count_typeof_edges(&staging);
assert_eq!(
typeof_count, 3,
"Expected exactly 3 TypeOf edges from GADT constructors, got {typeof_count}"
);
}
#[test]
fn test_typeof_class_method_name_collision() {
let source = r"
run :: String -> IO ()
run s = putStrLn s
class Runner a where
run :: a -> Int
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("run"),
"String",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(String) for top-level run"
);
assert!(
!has_typeof_edge_exact(
&staging,
"run",
"a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Top-level run should NOT have Parameter(a) from class method"
);
assert!(
!has_typeof_edge_exact(
&staging,
"run",
"Int",
Some(TypeOfContext::Return),
Some(0),
None
),
"Top-level run should NOT have Return(Int) from class method"
);
assert!(
has_typeof_edge(
&staging,
Some("Runner.run"),
"a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(a) for class method Runner.run"
);
}
#[test]
fn test_typeof_constrained_class_method() {
let source = r"
class Displayable a where
display :: Show a => a -> String
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("Displayable.display"),
"Show a",
Some(TypeOfContext::Constraint),
None,
None
),
"Expected Constraint(Show a) for constrained class method"
);
assert!(
has_typeof_edge(
&staging,
Some("Displayable.display"),
"a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(a) for constrained class method"
);
assert!(
has_typeof_edge(
&staging,
Some("Displayable.display"),
"String",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(String) for constrained class method"
);
}
#[test]
fn test_typeof_module_qualified_class_collision() {
let source = r"
module M where
run :: String -> IO ()
run s = putStrLn s
class Runner a where
run :: a -> Int
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("M.run"),
"String",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(String) for module-qualified top-level M.run"
);
assert!(
has_typeof_edge(
&staging,
Some("M.Runner.run"),
"a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(a) for module-qualified class method M.Runner.run"
);
assert!(
!has_typeof_edge_exact(
&staging,
"M.run",
"a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"M.run should NOT have Parameter(a) from class method"
);
}
#[test]
fn test_typeof_forall_signature() {
let source = r"
identity :: forall a. a -> a
identity x = x
constant :: forall a b. a -> b -> a
constant x _ = x
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("identity"),
"a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(a) for identity"
);
assert!(
has_typeof_edge(
&staging,
Some("identity"),
"a",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(a) for identity"
);
assert!(
has_typeof_edge(
&staging,
Some("constant"),
"a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(a, 0) for constant"
);
assert!(
has_typeof_edge(
&staging,
Some("constant"),
"b",
Some(TypeOfContext::Parameter),
Some(1),
None
),
"Expected Parameter(b, 1) for constant"
);
assert!(
has_typeof_edge(
&staging,
Some("constant"),
"a",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(a) for constant"
);
assert!(
!has_typeof_edge_full_exact(
&staging,
"identity",
"forall a. a -> a",
Some(TypeOfContext::Return),
Some(0),
None
),
"identity should NOT have Return('forall a. a -> a') — forall must be unwrapped"
);
}
#[test]
fn test_typeof_forall_constraint_signature() {
let source = r"
display :: forall a. Show a => a -> String
display = show
render :: forall a b. (Show a, Ord b) => a -> b -> String
render x y = show x ++ show y
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("display"),
"Show a",
Some(TypeOfContext::Constraint),
None,
None
),
"Expected Constraint(Show a) for display"
);
assert!(
has_typeof_edge(
&staging,
Some("display"),
"a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(a) for display"
);
assert!(
has_typeof_edge(
&staging,
Some("display"),
"String",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(String) for display"
);
assert!(
has_typeof_edge(
&staging,
Some("render"),
"Show a, Ord b",
Some(TypeOfContext::Constraint),
None,
None
),
"Expected Constraint for render with multiple constraints"
);
assert!(
has_typeof_edge(
&staging,
Some("render"),
"a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(a, 0) for render"
);
assert!(
has_typeof_edge(
&staging,
Some("render"),
"b",
Some(TypeOfContext::Parameter),
Some(1),
None
),
"Expected Parameter(b, 1) for render"
);
assert!(
has_typeof_edge(
&staging,
Some("render"),
"String",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(String) for render"
);
}
#[test]
fn test_typeof_forall_class_method() {
let source = r"
class Container f where
extract :: forall a. Show a => f a -> String
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("Container.extract"),
"Show a",
Some(TypeOfContext::Constraint),
None,
None
),
"Expected Constraint(Show a) for Container.extract"
);
assert!(
has_typeof_edge(
&staging,
Some("Container.extract"),
"f a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(f a) for Container.extract"
);
assert!(
has_typeof_edge(
&staging,
Some("Container.extract"),
"String",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(String) for Container.extract"
);
assert!(
!has_typeof_edge_full_exact(
&staging,
"extract",
"Show a",
Some(TypeOfContext::Constraint),
None,
None
),
"Bare 'extract' should NOT have Constraint — only Container.extract should"
);
}
#[test]
fn test_typeof_rank2_forall_not_decomposed() {
let source = r#"
foo :: a -> forall b. b -> a
foo x _ = x
bar :: Int -> forall a. Show a => a -> String
bar _ = show
baz :: a -> (forall b. b -> a)
baz x _ = x
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("foo"),
"a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(a, 0) for foo"
);
assert!(
has_typeof_edge(
&staging,
Some("foo"),
"forall b. b -> a",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return('forall b. b -> a') for foo — forall in return position must not be unwrapped"
);
assert!(
!has_typeof_edge_full_exact(
&staging,
"foo",
"b",
Some(TypeOfContext::Parameter),
Some(1),
None
),
"foo should NOT have Parameter(b, 1) — rank-2 forall must not be decomposed"
);
assert!(
has_typeof_edge(
&staging,
Some("bar"),
"Int",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(Int, 0) for bar"
);
assert!(
has_typeof_edge(
&staging,
Some("bar"),
"forall a. Show a => a -> String",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return('forall a. Show a => a -> String') for bar"
);
assert!(
!has_typeof_edge_exact(
&staging,
"bar",
"Show a",
Some(TypeOfContext::Constraint),
None,
None
),
"bar should NOT have Constraint(Show a) — constraint is inside forall return type"
);
assert!(
!has_typeof_edge_full_exact(
&staging,
"bar",
"a",
Some(TypeOfContext::Parameter),
Some(1),
None
),
"bar should NOT have Parameter(a, 1) — rank-2 forall must not be decomposed"
);
assert!(
has_typeof_edge(
&staging,
Some("baz"),
"a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(a, 0) for baz"
);
assert!(
has_typeof_edge(
&staging,
Some("baz"),
"forall b. b -> a",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return('forall b. b -> a') for baz — parens stripped, forall preserved"
);
assert!(
!has_typeof_edge_full_exact(
&staging,
"baz",
"b",
Some(TypeOfContext::Parameter),
Some(1),
None
),
"baz should NOT have Parameter(b, 1) — parenthesized rank-2 forall must not be decomposed"
);
}
#[test]
fn test_typeof_signature_type_field_is_not_quantified_type() {
let source = r"
plain :: Int -> String
plain = show
withForall :: forall a. a -> a
withForall x = x
withConstraint :: Show a => a -> String
withConstraint = show
withBoth :: forall a. Show a => a -> String
withBoth = show
";
let (tree, _content) = parse_haskell(source);
let root = tree.root_node();
let mut type_kinds = Vec::new();
let mut cursor = root.walk();
for decl in root
.child_by_field_name("children")
.unwrap_or(root)
.children(&mut cursor)
{
if decl.kind() == "signature"
&& let Some(type_node) = decl.child_by_field_name("type")
{
type_kinds.push(type_node.kind().to_string());
}
}
if type_kinds.is_empty() {
let decls = root.named_child(0).unwrap_or(root);
let mut cursor2 = decls.walk();
for decl in decls.children(&mut cursor2) {
if decl.kind() == "signature"
&& let Some(type_node) = decl.child_by_field_name("type")
{
type_kinds.push(type_node.kind().to_string());
}
}
}
assert!(
!type_kinds.is_empty(),
"Should have found at least one signature"
);
for kind in &type_kinds {
assert_ne!(
kind, "quantified_type",
"signature.type emitted 'quantified_type' — grammar changed! \
Update process_type_signature to handle quantified_type at signature level. \
Found kinds: {type_kinds:?}"
);
}
assert!(
type_kinds.contains(&"function".to_string()),
"Expected 'function' for 'Int -> String', got: {type_kinds:?}"
);
assert!(
type_kinds.contains(&"forall".to_string()),
"Expected 'forall' for 'forall a. ...', got: {type_kinds:?}"
);
assert!(
type_kinds.contains(&"context".to_string()),
"Expected 'context' for 'Show a => ...', got: {type_kinds:?}"
);
}
#[test]
fn test_typeof_parenthesized_constrained_signature() {
let source = r#"
showIt :: (Show a => a -> String)
showIt = show
showBoth :: (Show a, Ord a) => (a -> String)
showBoth = show
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("showIt"),
"Show a",
Some(TypeOfContext::Constraint),
None,
None
),
"Expected Constraint(Show a) for showIt — parens must be unwrapped to find context"
);
assert!(
has_typeof_edge(
&staging,
Some("showIt"),
"a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(a, 0) for showIt"
);
assert!(
has_typeof_edge(
&staging,
Some("showIt"),
"String",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(String) for showIt"
);
assert!(
has_typeof_edge(
&staging,
Some("showBoth"),
"Show a, Ord a",
Some(TypeOfContext::Constraint),
None,
None
),
"Expected Constraint for showBoth with multiple constraints"
);
assert!(
has_typeof_edge(
&staging,
Some("showBoth"),
"a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(a, 0) for showBoth — parens on return type must be unwrapped"
);
assert!(
has_typeof_edge(
&staging,
Some("showBoth"),
"String",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(String) for showBoth"
);
assert!(
!has_typeof_edge_exact(
&staging,
"showIt",
"Show a => a -> String",
Some(TypeOfContext::Return),
Some(0),
None
),
"showIt should NOT have opaque Return('Show a => a -> String') — parens must be unwrapped"
);
assert!(
!has_typeof_edge_exact(
&staging,
"showBoth",
"a -> String",
Some(TypeOfContext::Return),
Some(0),
None
),
"showBoth should NOT have opaque Return('a -> String') — parenthesized return must be decomposed"
);
}
#[test]
fn test_typeof_parenthesized_forall_signature() {
let source = r#"
idParens :: (forall a. a -> a)
idParens = id
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("idParens"),
"a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(a, 0) for idParens — parens→forall must be unwrapped"
);
assert!(
has_typeof_edge(
&staging,
Some("idParens"),
"a",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(a, 0) for idParens"
);
assert!(
!has_typeof_edge_exact(
&staging,
"idParens",
"forall a. a -> a",
Some(TypeOfContext::Return),
Some(0),
None
),
"idParens should NOT have opaque Return('forall a. a -> a') — forall must be unwrapped at top level"
);
}
#[test]
fn test_typeof_parenthesized_forall_constrained_signature() {
let source = r#"
showParens :: (forall a. Show a => a -> String)
showParens = show
"#;
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("showParens"),
"Show a",
Some(TypeOfContext::Constraint),
None,
None
),
"Expected Constraint(Show a) for showParens — parens→forall→context chain"
);
assert!(
has_typeof_edge(
&staging,
Some("showParens"),
"a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(a, 0) for showParens"
);
assert!(
has_typeof_edge(
&staging,
Some("showParens"),
"String",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(String, 0) for showParens"
);
assert!(
!has_typeof_edge_exact(
&staging,
"showParens",
"forall a. Show a => a -> String",
Some(TypeOfContext::Return),
Some(0),
None
),
"showParens should NOT have opaque Return — full chain must unwrap"
);
}
#[test]
fn test_typeof_parenthesized_forall_class_method() {
let source = r"
class Wrapper f where
unwrap :: (forall a. f a -> a)
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("Wrapper.unwrap"),
"f a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(f a, 0) for Wrapper.unwrap — parens→forall must be unwrapped"
);
assert!(
has_typeof_edge(
&staging,
Some("Wrapper.unwrap"),
"a",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(a, 0) for Wrapper.unwrap"
);
assert!(
!has_typeof_edge_exact(
&staging,
"Wrapper.unwrap",
"forall a. f a -> a",
Some(TypeOfContext::Return),
Some(0),
None
),
"Wrapper.unwrap should NOT have opaque Return — parens→forall must be unwrapped in class methods too"
);
}
#[test]
fn test_typeof_parenthesized_constrained_class_method() {
let source = r"
class Displayable f where
display :: (Show a => f a -> String)
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("Displayable.display"),
"Show a",
Some(TypeOfContext::Constraint),
None,
None
),
"Expected Constraint(Show a) for Displayable.display — parens must be unwrapped to find context"
);
assert!(
has_typeof_edge(
&staging,
Some("Displayable.display"),
"f a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(f a, 0) for Displayable.display"
);
assert!(
has_typeof_edge(
&staging,
Some("Displayable.display"),
"String",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(String, 0) for Displayable.display"
);
assert!(
!has_typeof_edge_exact(
&staging,
"Displayable.display",
"Show a => f a -> String",
Some(TypeOfContext::Return),
Some(0),
None
),
"Displayable.display should NOT have opaque Return — parens must be unwrapped to expose context"
);
}
#[test]
fn test_typeof_parenthesized_forall_constrained_class_method() {
let source = r"
class Formatter f where
format :: (forall a. Show a => f a -> String)
formatOrd :: (Show a, Ord a) => (f a -> String)
";
let (tree, content) = parse_haskell(source);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_typeof_edge(
&staging,
Some("Formatter.format"),
"Show a",
Some(TypeOfContext::Constraint),
None,
None
),
"Expected Constraint(Show a) for Formatter.format — parens→forall→context chain"
);
assert!(
has_typeof_edge(
&staging,
Some("Formatter.format"),
"f a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(f a, 0) for Formatter.format"
);
assert!(
has_typeof_edge(
&staging,
Some("Formatter.format"),
"String",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(String, 0) for Formatter.format"
);
assert!(
!has_typeof_edge_exact(
&staging,
"Formatter.format",
"forall a. Show a => f a -> String",
Some(TypeOfContext::Return),
Some(0),
None
),
"Formatter.format should NOT have opaque Return — full chain must unwrap"
);
assert!(
has_typeof_edge(
&staging,
Some("Formatter.formatOrd"),
"Show a, Ord a",
Some(TypeOfContext::Constraint),
None,
None
),
"Expected multi-Constraint for Formatter.formatOrd"
);
assert!(
has_typeof_edge(
&staging,
Some("Formatter.formatOrd"),
"f a",
Some(TypeOfContext::Parameter),
Some(0),
None
),
"Expected Parameter(f a, 0) for Formatter.formatOrd — parenthesized return decomposed"
);
assert!(
has_typeof_edge(
&staging,
Some("Formatter.formatOrd"),
"String",
Some(TypeOfContext::Return),
Some(0),
None
),
"Expected Return(String, 0) for Formatter.formatOrd"
);
assert!(
!has_typeof_edge_exact(
&staging,
"Formatter.formatOrd",
"f a -> String",
Some(TypeOfContext::Return),
Some(0),
None
),
"Formatter.formatOrd should NOT have opaque Return('f a -> String') — parens return must decompose"
);
}
fn has_reference_edge(staging: &StagingGraph, source_name: &str, target_type: &str) -> bool {
let nodes = build_node_lookup(staging);
for op in staging.operations() {
if let StagingOp::AddEdge {
source,
target,
kind,
..
} = op
{
if !matches!(kind, UnifiedEdgeKind::References) {
continue;
}
let src_name = nodes.get(source).map(|(n, _)| n.as_str());
let tgt_name = nodes.get(target).map(|(n, _)| n.as_str());
if src_name.is_some_and(|n| n.contains(source_name))
&& tgt_name.is_some_and(|n| n.contains(target_type))
{
return true;
}
}
}
false
}
fn count_reference_edges(staging: &StagingGraph, source_name: &str) -> usize {
let nodes = build_node_lookup(staging);
staging
.operations()
.iter()
.filter(|op| {
if let StagingOp::AddEdge {
source,
kind: UnifiedEdgeKind::References,
..
} = op
{
nodes
.get(source)
.is_some_and(|(n, _)| n.contains(source_name))
} else {
false
}
})
.count()
}
#[test]
fn test_references_simple_signature() {
let code = r#"
module Ref where
foo :: Int -> Int
foo x = x
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_reference_edge(&staging, "foo", "Int"),
"foo should reference Int"
);
assert_eq!(
count_reference_edges(&staging, "foo"),
1,
"Deduplication: Int -> Int should produce exactly 1 References edge"
);
}
#[test]
fn test_references_multi_param() {
let code = r#"
module Ref where
calc :: Int -> String -> Bool
calc x y = True
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "calc", "Int"));
assert!(has_reference_edge(&staging, "calc", "String"));
assert!(has_reference_edge(&staging, "calc", "Bool"));
assert_eq!(count_reference_edges(&staging, "calc"), 3);
}
#[test]
fn test_references_complex_type() {
let code = r#"
module Ref where
proc :: IO String -> Maybe Int -> Bool
proc x y = True
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "proc", "IO"));
assert!(has_reference_edge(&staging, "proc", "String"));
assert!(has_reference_edge(&staging, "proc", "Maybe"));
assert!(has_reference_edge(&staging, "proc", "Int"));
assert!(has_reference_edge(&staging, "proc", "Bool"));
assert_eq!(count_reference_edges(&staging, "proc"), 5);
}
#[test]
fn test_references_no_type_vars() {
let code = r#"
module Ref where
identity :: a -> a
identity x = x
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert_eq!(
count_reference_edges(&staging, "identity"),
0,
"Type variables should not produce References edges"
);
}
#[test]
fn test_references_constraint() {
let code = r#"
module Ref where
showIt :: Show a => a -> String
showIt x = show x
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "showIt", "Show"));
assert!(has_reference_edge(&staging, "showIt", "String"));
assert_eq!(count_reference_edges(&staging, "showIt"), 2);
}
#[test]
fn test_references_multi_constraint() {
let code = r#"
module Ref where
f :: (Show a, Ord a) => a -> String
f x = show x
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "f", "Show"));
assert!(has_reference_edge(&staging, "f", "Ord"));
assert!(has_reference_edge(&staging, "f", "String"));
assert_eq!(count_reference_edges(&staging, "f"), 3);
}
#[test]
fn test_references_qualified_type() {
let code = r#"
module Ref where
f :: Data.Map.Map String Int -> Bool
f x = True
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "f", "Data.Map.Map"));
assert!(has_reference_edge(&staging, "f", "String"));
assert!(has_reference_edge(&staging, "f", "Int"));
assert!(has_reference_edge(&staging, "f", "Bool"));
assert_eq!(count_reference_edges(&staging, "f"), 4);
}
#[test]
fn test_references_data_record() {
let code = r#"
module Ref where
data Rec = Rec { name :: String, age :: Int }
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "Rec", "String"));
assert!(has_reference_edge(&staging, "Rec", "Int"));
}
#[test]
fn test_references_data_prefix() {
let code = r#"
module Ref where
data Wrapper = Wrapper Int String
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "Wrapper", "Int"));
assert!(has_reference_edge(&staging, "Wrapper", "String"));
}
#[test]
fn test_references_newtype() {
let code = r#"
module Ref where
newtype Wrapped = Wrapped Int
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "Wrapped", "Int"));
}
#[test]
fn test_references_type_synonym() {
let code = r#"
module Ref where
type Table = Map String Int
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "Table", "Map"));
assert!(has_reference_edge(&staging, "Table", "String"));
assert!(has_reference_edge(&staging, "Table", "Int"));
}
#[test]
fn test_references_class_method() {
let code = r#"
module Ref where
class Container f where
extract :: f a -> Int
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "Container.extract", "Int"));
}
#[test]
fn test_references_no_edges_without_sig() {
let code = r#"
module Ref where
bar = 42
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert_eq!(
count_reference_edges(&staging, "bar"),
0,
"Functions without type signatures should not have References edges"
);
}
#[test]
fn test_references_forall() {
let code = r#"
module Ref where
foo :: forall a. a -> Int
foo x = 42
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "foo", "Int"));
assert_eq!(count_reference_edges(&staging, "foo"), 1);
}
#[test]
fn test_references_gadt() {
let code = r#"
module Ref where
data Expr a where
Lit :: Int -> Expr Int
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "Expr", "Int"));
}
#[test]
fn test_references_strict_lazy_fields() {
let code = r#"
module Ref where
data Strict = Strict !Int ~String
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "Strict", "Int"));
assert!(has_reference_edge(&staging, "Strict", "String"));
}
#[test]
fn test_references_rank2_boundary() {
let code = r#"
module Ref where
foo :: Int -> forall a. Show a => a -> String
foo x = show
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(
has_reference_edge(&staging, "foo", "Int"),
"Int parameter should be referenced"
);
assert!(
has_reference_edge(&staging, "foo", "Show"),
"Show from rank-2 constraint should be referenced"
);
assert!(
has_reference_edge(&staging, "foo", "String"),
"String from rank-2 return should be referenced"
);
assert_eq!(
count_reference_edges(&staging, "foo"),
3,
"Expected exactly 3 References edges: Int, Show, String"
);
}
#[test]
fn test_references_dedup_repeated_type() {
let code = r#"
module Ref where
swap :: (Int, Int) -> (Int, Int)
swap (a, b) = (b, a)
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "swap", "Int"));
assert_eq!(
count_reference_edges(&staging, "swap"),
1,
"Int appears 4 times but should produce exactly 1 References edge"
);
}
#[test]
fn test_references_dedup_record_shared_type() {
let code = r#"
module Ref where
data Rec = Rec { name :: Int, age :: Int }
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "Rec", "Int"));
assert_eq!(
count_reference_edges(&staging, "Rec"),
1,
"Record with two Int fields should produce exactly 1 Rec -> Int References edge"
);
}
#[test]
fn test_references_dedup_prefix_shared_type() {
let code = r#"
module Ref where
data Pair = Pair Int Int
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "Pair", "Int"));
assert_eq!(
count_reference_edges(&staging, "Pair"),
1,
"Prefix constructor with two Int fields should produce exactly 1 References edge"
);
}
#[test]
fn test_references_dedup_multi_constructor_prefix() {
let code = r#"
module Ref where
data T = A Int | B Int
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "T", "Int"));
assert_eq!(
count_reference_edges(&staging, "T"),
1,
"data T = A Int | B Int should produce exactly 1 T -> Int References edge"
);
}
#[test]
fn test_references_dedup_multi_constructor_record() {
let code = r#"
module Ref where
data T = A { x :: Int } | B { y :: Int }
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "T", "Int"));
assert_eq!(
count_reference_edges(&staging, "T"),
1,
"data T = A {{ x :: Int }} | B {{ y :: Int }} should produce exactly 1 T -> Int References edge"
);
}
#[test]
fn test_references_dedup_multi_constructor_gadt() {
let code = r#"
module Ref where
data E a where
Lit :: Int -> E Int
Add :: Int -> Int -> E Int
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "E", "Int"));
assert!(has_reference_edge(&staging, "E", "E"));
assert_eq!(
count_reference_edges(&staging, "E"),
2,
"GADT: 1 for Int (deduped across constructors) + 1 for E (self-ref in return type)"
);
}
#[test]
fn test_references_dedup_multi_constructor_infix() {
let code = r#"
module Ref where
data T = Int `A` Int | Int `B` Int
"#;
let (tree, content) = parse_haskell(code);
let mut staging = StagingGraph::new();
let builder = HaskellGraphBuilder::default();
builder
.build_graph(&tree, &content, Path::new("test.hs"), &mut staging)
.unwrap();
assert!(has_reference_edge(&staging, "T", "Int"));
assert_eq!(
count_reference_edges(&staging, "T"),
1,
"data T = Int `A` Int | Int `B` Int should produce exactly 1 T -> Int References edge"
);
}
}