use sqry_core::graph::unified::StagingGraph;
use sqry_core::graph::unified::build::GraphBuildHelper;
use sqry_core::graph::unified::node::NodeId;
use sqry_core::graph::{GraphBuilder, GraphBuilderError, GraphResult, Language, Span};
use std::collections::HashMap;
use std::ops::Range;
use std::path::Path;
use tree_sitter::{Node, Query, QueryCursor, StreamingIterator, Tree};
#[derive(Debug, Default, Clone)]
pub struct PerlGraphBuilder;
impl GraphBuilder for PerlGraphBuilder {
fn language(&self) -> Language {
Language::Perl
}
fn build_graph(
&self,
tree: &Tree,
content: &[u8],
file: &Path,
staging: &mut StagingGraph,
) -> GraphResult<()> {
let mut helper = GraphBuildHelper::new(staging, file, Language::Perl);
let package_ranges = extract_package_ranges(tree.root_node(), content).map_err(|e| {
GraphBuilderError::ParseError {
span: Span::default(),
reason: e,
}
})?;
let contexts = extract_subroutines(tree.root_node(), content, &package_ranges, "main")
.map_err(|e| GraphBuilderError::ParseError {
span: Span::default(),
reason: e,
})?;
let mut context_to_node: HashMap<String, NodeId> = HashMap::new();
for context in &contexts {
let span = Some(Span::from_bytes(
context.byte_range.start,
context.byte_range.end,
));
let visibility = extract_visibility(&context.qualified_name);
let node_id = helper.add_function_with_visibility(
&context.qualified_name,
span,
false,
false,
Some(visibility),
);
context_to_node.insert(context.qualified_name.clone(), node_id);
}
let mut package_modules: HashMap<String, NodeId> = HashMap::new();
for context in &contexts {
let package_name = if let Some(idx) = context.qualified_name.rfind("::") {
&context.qualified_name[..idx]
} else {
"main"
};
let module_id = package_modules
.entry(package_name.to_string())
.or_insert_with(|| helper.add_module(package_name, None));
let visibility = extract_visibility(&context.qualified_name);
if visibility == "public"
&& let Some(&node_id) = context_to_node.get(&context.qualified_name)
{
helper.add_export_edge(*module_id, node_id);
}
}
visit_node_for_calls(
tree.root_node(),
content,
&contexts,
&mut helper,
&context_to_node,
);
collect_import_edges(tree.root_node(), content, &mut helper)?;
Ok(())
}
}
fn extract_visibility(name: &str) -> &'static str {
let simple_name = name.split("::").last().unwrap_or(name);
if simple_name.starts_with('_') {
"private"
} else {
"public"
}
}
#[derive(Debug, Clone)]
struct CallableContext {
qualified_name: String,
byte_range: Range<usize>,
}
impl CallableContext {
fn contains_offset(&self, offset: usize) -> bool {
self.byte_range.contains(&offset)
}
}
fn extract_package_ranges(
root: Node,
content: &[u8],
) -> Result<Vec<(String, Range<usize>)>, String> {
let query = Query::new(
&tree_sitter_perl_sqry::language(),
r"
(package_statement
name: (_) @package.name) @package
",
)
.map_err(|e| format!("Failed to create package query: {e}"))?;
let capture_names = query.capture_names();
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(&query, root, content);
let mut packages: Vec<(String, usize)> = Vec::new();
while let Some(m) = matches.next() {
let mut package_name: Option<String> = None;
let mut package_start: Option<usize> = None;
for capture in m.captures {
let capture_name = capture_names[capture.index as usize];
match capture_name {
"package.name" => {
package_name = Some(
capture
.node
.utf8_text(content)
.map_err(|e| format!("Failed to extract package name: {e}"))?
.to_string(),
);
}
"package" => {
package_start = Some(capture.node.start_byte());
}
_ => {}
}
}
if let (Some(name), Some(start)) = (package_name, package_start) {
packages.push((name, start));
}
}
packages.sort_by_key(|(_, start)| *start);
let file_len = content.len();
let mut ranges: Vec<(String, Range<usize>)> = Vec::new();
for i in 0..packages.len() {
let (name, start) = &packages[i];
let end = if i + 1 < packages.len() {
packages[i + 1].1
} else {
file_len
};
ranges.push((name.clone(), *start..end));
}
Ok(ranges)
}
fn extract_subroutines(
root: Node,
content: &[u8],
package_ranges: &[(String, Range<usize>)],
default_package: &str,
) -> Result<Vec<CallableContext>, String> {
let query = Query::new(
&tree_sitter_perl_sqry::language(),
r"
(subroutine_declaration_statement
name: (_) @sub.name) @sub
(method_declaration_statement
name: (_) @method.name) @method
(anonymous_subroutine_expression) @anon
",
)
.map_err(|e| format!("Failed to create subroutine query: {e}"))?;
let capture_names = query.capture_names();
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(&query, root, content);
let mut contexts: Vec<CallableContext> = Vec::new();
while let Some(m) = matches.next() {
let mut sub_name: Option<String> = None;
let mut sub_node: Option<Node> = None;
let mut is_anonymous = false;
for capture in m.captures {
let capture_name = capture_names[capture.index as usize];
match capture_name {
"sub.name" | "method.name" => {
sub_name = Some(
capture
.node
.utf8_text(content)
.map_err(|e| format!("Failed to extract subroutine name: {e}"))?
.to_string(),
);
}
"sub" | "method" => {
sub_node = Some(capture.node);
}
"anon" => {
sub_node = Some(capture.node);
is_anonymous = true;
}
_ => {}
}
}
if is_anonymous && let Some(node) = sub_node {
sub_name = Some(synth_anon_name_for_node(
content,
package_ranges,
default_package,
node,
));
}
if let (Some(name), Some(node)) = (sub_name, sub_node) {
let current_package =
find_package_for_offset(node.start_byte(), package_ranges, default_package);
let qualified_name = format!("{current_package}::{name}");
let byte_range = node.start_byte()..node.end_byte();
contexts.push(CallableContext {
qualified_name,
byte_range,
});
}
}
Ok(contexts)
}
fn find_package_for_offset(
offset: usize,
package_ranges: &[(String, Range<usize>)],
default_package: &str,
) -> String {
package_ranges
.iter()
.find(|(_, range)| range.contains(&offset))
.map_or_else(|| default_package.to_string(), |(name, _)| name.clone())
}
fn synth_anon_name_for_node(
_content: &[u8],
package_ranges: &[(String, Range<usize>)],
default_package: &str,
node: Node,
) -> String {
let pkg = find_package_for_offset(node.start_byte(), package_ranges, default_package);
format!("{}::__ANON_{}_{}", pkg, node.start_byte(), node.end_byte())
}
fn visit_node_for_calls(
node: Node<'_>,
content: &[u8],
contexts: &[CallableContext],
helper: &mut GraphBuildHelper,
context_to_node: &HashMap<String, NodeId>,
) {
if matches!(
node.kind(),
"function_call_expression" | "method_call_expression"
) {
let offset = node.start_byte();
if let Some(context) = contexts.iter().find(|ctx| ctx.contains_offset(offset))
&& let Some(&caller_id) = context_to_node.get(&context.qualified_name)
{
if let Ok(call_text) = node.utf8_text(content) {
let callee = call_text
.split(|c: char| !c.is_alphanumeric() && c != '_' && c != ':')
.find(|s| !s.is_empty())
.unwrap_or("")
.to_string();
if !callee.is_empty() {
let callee_id = helper.add_function(&callee, None, false, false);
let argument_count = count_call_arguments(node);
let call_span = Span::from_bytes(node.start_byte(), node.end_byte());
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, contexts, helper, context_to_node);
}
}
fn count_call_arguments(call_node: Node<'_>) -> u8 {
let args_node = call_node
.child_by_field_name("arguments")
.or_else(|| call_node.child_by_field_name("argument_list"))
.or_else(|| {
let mut cursor = call_node.walk();
call_node
.children(&mut cursor)
.find(|child| matches!(child.kind(), "argument_list" | "list_expression"))
});
let Some(args_node) = args_node else {
return 255;
};
let count = args_node.named_child_count();
let capped = count.min(254);
u8::try_from(capped).unwrap_or(u8::MAX)
}
fn collect_import_edges(
root: Node,
content: &[u8],
helper: &mut GraphBuildHelper,
) -> GraphResult<()> {
let file_path = helper.file_path().to_string();
let importer_module_id = helper.add_module(&file_path, None);
let query = Query::new(
&tree_sitter_perl_sqry::language(),
r"
(use_statement
module: (_) @use.module)
(require_expression
(_) @require.target)
",
)
.map_err(|e| GraphBuilderError::ParseError {
span: Span::default(),
reason: format!("Failed to create import query: {e}"),
})?;
let capture_names = query.capture_names();
let mut cursor = QueryCursor::new();
let mut matches = cursor.matches(&query, root, content);
while let Some(m) = matches.next() {
for capture in m.captures {
let capture_name = capture_names[capture.index as usize];
let node = capture.node;
let import_name = match node.utf8_text(content) {
Ok(text) => {
let cleaned = text.trim().trim_matches('\'').trim_matches('"').to_string();
if cleaned.is_empty() {
continue;
}
cleaned
}
Err(_) => continue,
};
let span = Span::from_bytes(node.start_byte(), node.end_byte());
match capture_name {
"use.module" => {
let import_target_id = helper.add_import(&import_name, Some(span));
helper.add_import_edge(importer_module_id, import_target_id);
}
"require.target" => {
let import_target_id = helper.add_import(&import_name, Some(span));
helper.add_import_edge(importer_module_id, import_target_id);
}
_ => {}
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use sqry_core::graph::unified::build::test_helpers::*;
use sqry_core::graph::unified::node::NodeKind;
use std::path::PathBuf;
fn parse_perl(source: &str) -> Tree {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter_perl_sqry::language())
.expect("failed to set language");
parser.parse(source, None).expect("failed to parse")
}
#[test]
fn test_extracts_subroutines() {
let source = r"
package MyApp;
sub foo {
my ($x) = @_;
return $x;
}
sub bar {
return 42;
}
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "foo");
assert_has_node(&staging, "bar");
}
#[test]
fn test_extracts_methods() {
let source = r"
package MyClass;
method new ($class) {
return bless {}, $class;
}
method process ($self, $data) {
return $data * 2;
}
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "new");
assert_has_node(&staging, "process");
}
#[test]
fn test_creates_call_edges() {
let source = r"
package MyApp;
sub helper {
return 42;
}
sub caller {
helper();
}
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_call_edge(&staging, "MyApp::caller", "helper");
}
#[test]
fn test_multiple_packages() {
let source = r"
package Util;
sub helper {
return 1;
}
package Main;
sub process {
Util::helper();
}
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "Util");
assert_has_node(&staging, "Main");
assert_has_node(&staging, "helper");
assert_has_node(&staging, "process");
}
#[test]
fn test_main_package_default() {
let source = r"
sub top_level {
return 1;
}
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "main");
assert_has_node(&staging, "top_level");
}
#[test]
fn test_filters_external_calls() {
let source = r#"
package MyApp;
sub process {
# Call to user-defined function - should create edge
helper();
# Calls to CORE functions - should be filtered
print("hello");
die("error");
}
sub helper {
return 1;
}
"#;
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "process");
assert_has_node(&staging, "helper");
assert_has_call_edge(&staging, "MyApp::process", "helper");
}
#[test]
fn test_package_scope_resolution() {
let source = r"
package First;
sub util {
return 1;
}
package Second;
sub util {
return 2;
}
sub caller {
util(); # Should resolve to Second::util
}
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "caller");
assert_has_node(&staging, "util");
assert_has_call_edge(&staging, "Second::caller", "util");
}
#[test]
fn test_method_calls_with_bareword_invocant() {
let source = r"
package MyApp::Service;
sub new {
my ($class) = @_;
return bless {}, $class;
}
sub process {
my ($self, $data) = @_;
return $data * 2;
}
package main;
sub run {
# Bareword invocant - statically resolvable
my $service = MyApp::Service->new();
# Fully qualified method call
MyApp::Service->process(42);
}
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "run");
assert_has_node(&staging, "new");
assert_has_node(&staging, "process");
}
#[test]
fn test_anonymous_subroutines() {
let source = r"
package main;
sub takes_callback {
my ($callback) = @_;
# Can't statically resolve this call
}
sub run {
my $anon = sub {
return 42;
};
takes_callback($anon);
}
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "takes_callback");
assert_has_node(&staging, "run");
assert_has_node(&staging, "__ANON");
}
#[test]
fn test_callback_invocations() {
let source = r"
package main;
sub run {
my $cb = sub {
return 42;
};
# Direct callback invocation
$cb->();
}
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "run");
assert_has_node(&staging, "__ANON");
}
#[test]
#[ignore = "Pending tree-sitter support for `$cb()` direct coderef invocation"]
fn test_callback_invocations_without_arrow() {
let source = r"
package main;
sub run {
my $cb = sub {
return 7;
};
# Direct callback invocation without '->'
$cb();
}
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "run");
assert_has_node(&staging, "__ANON");
}
#[test]
fn test_variable_reassignment_scoped() {
let source = r"
package A;
sub m { 1 }
package B;
sub n { 1 }
package Main;
sub f {
my $o = A->m();
$o->m();
$o = B->n();
$o->n();
}
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "f");
assert_has_node(&staging, "m");
assert_has_node(&staging, "n");
}
#[test]
fn test_variable_shadowing_between_functions() {
let source = r"
package X;
sub m { 1 }
package Y;
sub n { 1 }
package Main;
sub f1 {
my $o = X->m();
$o->m();
}
sub f2 {
my $o = Y->n();
$o->n();
}
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "f1");
assert_has_node(&staging, "f2");
assert_has_node(&staging, "m");
assert_has_node(&staging, "n");
let call_edges = collect_call_edges(&staging);
assert!(call_edges.len() >= 4, "Expected at least 4 call edges");
}
#[test]
#[ignore = "Debug test to inspect AST - run with: cargo test -- --ignored --nocapture"]
fn debug_inspect_anonymous_sub_ast() {
let source = r"my $anon = sub { return 42; };";
let tree = parse_perl(source);
#[allow(clippy::items_after_statements)] fn print_tree(node: tree_sitter::Node, content: &[u8], indent: usize) {
let kind = node.kind();
let text = node.utf8_text(content).unwrap_or("<error>");
let display_text = if text.len() > 50 {
format!("{}...", &text[..50].replace('\n', "\\n"))
} else {
text.replace('\n', "\\n")
};
println!(
"{:indent$}{} [{}]",
"",
kind,
display_text,
indent = indent * 2
);
let mut cursor = node.walk();
for child in node.children(&mut cursor) {
print_tree(child, content, indent + 1);
}
}
println!("\n=== AST for anonymous sub: {source} ===");
print_tree(tree.root_node(), source.as_bytes(), 0);
println!("=== END AST ===\n");
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "__ANON");
}
#[test]
fn test_variable_invocant_resolution() {
let source = r"
package MyApp::Service;
sub new {
my ($class) = @_;
return bless {}, $class;
}
sub process {
my ($self, $data) = @_;
return $data * 2;
}
package main;
sub run {
# Variable assigned from method call - should be resolvable
my $service = MyApp::Service->new();
# Variable invocant should resolve to MyApp::Service
$service->process(42);
}
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "run");
assert_has_node(&staging, "new");
assert_has_node(&staging, "process");
let call_edges = collect_call_edges(&staging);
assert!(call_edges.len() >= 2, "Expected at least 2 call edges");
}
#[test]
fn test_variable_shadowing_with_lexical_scope() {
let source = r"
package MyApp;
sub new {
my ($class) = @_;
return bless {}, $class;
}
sub process {
my ($self) = @_;
return 1;
}
sub run {
# First scope: $obj is MyApp
my $obj = MyApp->new();
$obj->process(); # Should resolve to MyApp::process
}
sub helper {
# Second scope: different $obj, shadows the one in run()
my $obj = bless {}, 'MyApp';
$obj->process(); # Should also resolve to MyApp::process
}
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "new");
assert_has_node(&staging, "process");
assert_has_node(&staging, "run");
assert_has_node(&staging, "helper");
let call_edges = collect_call_edges(&staging);
assert!(call_edges.len() >= 3, "Expected at least 3 call edges");
}
#[test]
fn test_bless_constructor_resolution() {
let source = r"
package MyApp::Model;
sub new {
my ($class) = @_;
my $self = bless {}, $class;
return $self;
}
sub save {
my ($self) = @_;
return 1;
}
sub create {
my $model = bless {}, 'MyApp::Model';
$model->save();
}
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "new");
assert_has_node(&staging, "save");
assert_has_node(&staging, "create");
let call_edges = collect_call_edges(&staging);
assert!(
!call_edges.is_empty(),
"Expected at least 1 call edge from create"
);
}
#[test]
fn test_unresolved_variable_invocant_fallback() {
let source = r"
package MyApp;
sub process {
my ($self, $data) = @_;
return $data * 2;
}
sub run {
my $obj; # Uninitialized/unresolved
# Should fall back to current package (MyApp)
$obj->process(42);
}
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node(&staging, "process");
assert_has_node(&staging, "run");
let call_edges = collect_call_edges(&staging);
assert!(
!call_edges.is_empty(),
"Expected at least 1 call edge from run"
);
}
#[test]
fn test_module_nodes_always_present() {
let source = r"
package MyApp::Utils;
sub helper {
return 1;
}
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.unwrap();
assert_has_node_with_kind(&staging, "MyApp::Utils", NodeKind::Module);
assert_has_node(&staging, "helper");
}
}
#[cfg(test)]
mod active_tests {
use super::*;
use sqry_core::graph::unified::build::StagingOp;
use sqry_core::graph::unified::edge::EdgeKind as UnifiedEdgeKind;
use std::path::PathBuf;
fn parse_perl(source: &str) -> Tree {
let mut parser = tree_sitter::Parser::new();
parser
.set_language(&tree_sitter_perl_sqry::language())
.expect("failed to set language");
parser.parse(source, None).expect("failed to parse")
}
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 extract_call_edges(staging: &StagingGraph) -> Vec<&UnifiedEdgeKind> {
staging
.operations()
.iter()
.filter_map(|op| {
if let StagingOp::AddEdge { kind, .. } = op
&& matches!(kind, UnifiedEdgeKind::Calls { .. })
{
return Some(kind);
}
None
})
.collect()
}
#[test]
fn test_extracts_use_statement() {
let source = r"
use strict;
use warnings;
use Data::Dumper;
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.expect("build_graph should succeed");
let import_edges = extract_import_edges(&staging);
assert_eq!(import_edges.len(), 3, "Should extract three use statements");
}
#[test]
fn test_extracts_require_file() {
let source = r#"
require 'my_module.pm';
require "another_module.pl";
"#;
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.expect("build_graph should succeed");
let import_edges = extract_import_edges(&staging);
assert_eq!(
import_edges.len(),
2,
"Should extract two require statements"
);
}
#[test]
fn test_extracts_require_module() {
let source = r"
require My::Module;
require Another::Nested::Module;
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.expect("build_graph should succeed");
let import_edges = extract_import_edges(&staging);
assert_eq!(
import_edges.len(),
2,
"Should extract two module require statements"
);
}
#[test]
fn test_extracts_use_with_import_list() {
let source = r"
use Module::Name qw(func1 func2);
use Exporter qw(import);
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.expect("build_graph should succeed");
let import_edges = extract_import_edges(&staging);
assert_eq!(
import_edges.len(),
2,
"Should extract use statements with import lists"
);
}
#[test]
fn test_extracts_mixed_imports() {
let source = r"
use strict;
use warnings;
require 'utils.pm';
use Data::Dumper;
require Config;
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.expect("build_graph should succeed");
let import_edges = extract_import_edges(&staging);
assert_eq!(
import_edges.len(),
5,
"Should extract all use and require statements"
);
}
#[test]
fn test_extracts_function_calls() {
let source = r"
package MyApp;
sub caller_func {
helper_func();
another_func(1, 2, 3);
}
sub helper_func {
return 1;
}
sub another_func {
my ($a, $b, $c) = @_;
return $a + $b + $c;
}
";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.expect("build_graph should succeed");
let call_edges = extract_call_edges(&staging);
assert!(
call_edges.len() >= 2,
"Should extract at least 2 function call edges, got {}",
call_edges.len()
);
}
#[test]
fn test_no_false_positives_empty_file() {
let source = "";
let tree = parse_perl(source);
let mut staging = StagingGraph::new();
let builder = PerlGraphBuilder;
let file = PathBuf::from("test.pl");
builder
.build_graph(&tree, source.as_bytes(), &file, &mut staging)
.expect("build_graph should succeed");
let import_edges = extract_import_edges(&staging);
assert!(
import_edges.is_empty(),
"Empty file should have no import edges"
);
}
#[test]
fn test_perl_graph_builder_language() {
let builder = PerlGraphBuilder;
assert_eq!(builder.language(), Language::Perl);
}
}