use std::collections::HashSet;
use tree_sitter::Node;
use crate::model::{Edge, EdgeType, Language, Node as ModelNode, NodeLabel};
use crate::resolve::{FqnGenerator, ScopeContext, ScopeResolverRegistry};
use super::dedupe_qn;
use super::error::{ParseError, Result};
use super::extractor::{
CallInfo, ExternInfo, ExtractResult, Extractor, ImportInfo, ReadInfo, WriteInfo,
};
use super::parser_factory::ParserFactory;
pub struct FortranExtractor {
_priv: (),
}
impl FortranExtractor {
#[must_use]
pub const fn new() -> Self {
Self { _priv: () }
}
}
impl Default for FortranExtractor {
fn default() -> Self {
Self::new()
}
}
impl Extractor for FortranExtractor {
fn language(&self) -> Language {
Language::Fortran
}
fn extract(&self, source: &str, file_path: &str, project: &str) -> Result<ExtractResult> {
let mut result = ExtractResult::new(file_path, Language::Fortran);
let mut parser = ParserFactory::create_parser(Language::Fortran)?;
let effective_source = preprocess_fixed_form_comments(source, file_path);
let tree = parser
.parse(effective_source.as_str(), None)
.ok_or_else(|| ParseError::ParseFailed {
file_path: file_path.to_string(),
})?;
let root = tree.root_node();
let declared_arrays = collect_declared_arrays(root, &effective_source);
let registry = ScopeResolverRegistry::new();
let ctx = VisitContext {
file_path,
project,
current_func: None,
current_parent: None,
resolver: ®istry,
declared_arrays: &declared_arrays,
};
for i in 0..root.named_child_count() as u32 {
if let Some(child) = root.named_child(i) {
visit_node(child, &effective_source, &ctx, &mut result);
}
}
Ok(result)
}
}
struct VisitContext<'a> {
file_path: &'a str,
project: &'a str,
current_func: Option<&'a str>,
current_parent: Option<&'a str>,
resolver: &'a ScopeResolverRegistry,
declared_arrays: &'a HashSet<String>,
}
fn visit_node(node: Node, source: &str, ctx: &VisitContext<'_>, result: &mut ExtractResult) {
match node.kind() {
"module" => {
extract_module(node, source, ctx, result);
let scope_ctx = ScopeContext {
source,
file_path: ctx.file_path,
project: ctx.project,
current_parent: ctx.current_parent,
};
let scope = ctx
.resolver
.get(Language::Fortran)
.and_then(|r| r.resolve(node, &scope_ctx));
let mod_name = scope.as_ref().map(|s| s.name.as_str());
let combined = combine_scope(ctx.current_parent, mod_name);
let child_ctx = VisitContext {
file_path: ctx.file_path,
project: ctx.project,
current_func: None,
current_parent: combined.as_deref(),
resolver: ctx.resolver,
declared_arrays: ctx.declared_arrays,
};
visit_children(node, source, &child_ctx, result);
}
"subroutine" => {
extract_subroutine_or_function(node, source, ctx, result, "subroutine_statement");
extract_bind_c(node, source, ctx, result, "subroutine_statement");
let scope_ctx = ScopeContext {
source,
file_path: ctx.file_path,
project: ctx.project,
current_parent: ctx.current_parent,
};
let scope = ctx
.resolver
.get(Language::Fortran)
.and_then(|r| r.resolve(node, &scope_ctx));
let func_name = scope.as_ref().map(|s| s.name.as_str());
let child_ctx = VisitContext {
file_path: ctx.file_path,
project: ctx.project,
current_func: func_name,
current_parent: ctx.current_parent,
resolver: ctx.resolver,
declared_arrays: ctx.declared_arrays,
};
visit_children(node, source, &child_ctx, result);
}
"function" => {
extract_subroutine_or_function(node, source, ctx, result, "function_statement");
extract_bind_c(node, source, ctx, result, "function_statement");
let scope_ctx = ScopeContext {
source,
file_path: ctx.file_path,
project: ctx.project,
current_parent: ctx.current_parent,
};
let scope = ctx
.resolver
.get(Language::Fortran)
.and_then(|r| r.resolve(node, &scope_ctx));
let func_name = scope.as_ref().map(|s| s.name.as_str());
let child_ctx = VisitContext {
file_path: ctx.file_path,
project: ctx.project,
current_func: func_name,
current_parent: ctx.current_parent,
resolver: ctx.resolver,
declared_arrays: ctx.declared_arrays,
};
visit_children(node, source, &child_ctx, result);
}
"program" => {
extract_program(node, source, ctx, result);
let scope_ctx = ScopeContext {
source,
file_path: ctx.file_path,
project: ctx.project,
current_parent: ctx.current_parent,
};
let scope = ctx
.resolver
.get(Language::Fortran)
.and_then(|r| r.resolve(node, &scope_ctx));
let func_name = scope.as_ref().map(|s| s.name.as_str());
let child_ctx = VisitContext {
file_path: ctx.file_path,
project: ctx.project,
current_func: func_name,
current_parent: ctx.current_parent,
resolver: ctx.resolver,
declared_arrays: ctx.declared_arrays,
};
visit_children(node, source, &child_ctx, result);
}
"use_statement" => {
extract_use(node, source, result);
}
"subroutine_call" | "call_statement" => {
extract_call(node, source, ctx, result);
visit_children(node, source, ctx, result);
}
"call_expression" => {
let callee = first_identifier_child(node, source);
let is_array_access = callee
.map(|name| ctx.declared_arrays.contains(name))
.unwrap_or(false);
if !is_array_access {
extract_call(node, source, ctx, result);
}
visit_children(node, source, ctx, result);
}
"assignment_statement" => {
if let Some(func) = ctx.current_func {
if let Some(left) = node.child_by_field_name("left") {
if let Some(name) = identifier_text(left, source) {
result.writes.push(WriteInfo {
writer_qn: Some(make_qn(
ctx.file_path,
func,
ctx.project,
ctx.current_parent,
)),
var_name: name,
line: node.start_position().row as u32 + 1,
});
}
}
}
visit_children(node, source, ctx, result);
}
"do_loop" => {
if let Some(func) = ctx.current_func {
if let Some(loop_var) = do_loop_variable(node, source) {
result.writes.push(WriteInfo {
writer_qn: Some(make_qn(
ctx.file_path,
func,
ctx.project,
ctx.current_parent,
)),
var_name: loop_var,
line: node.start_position().row as u32 + 1,
});
}
}
visit_children(node, source, ctx, result);
}
"identifier" => {
if let Some(func) = ctx.current_func {
if is_fortran_read_position(node) {
if let Some(name) = node_text(node, source).map(String::from) {
result.reads.push(ReadInfo {
reader_qn: Some(make_qn(
ctx.file_path,
func,
ctx.project,
ctx.current_parent,
)),
var_name: name,
line: node.start_position().row as u32 + 1,
});
}
}
}
visit_children(node, source, ctx, result);
}
_ => {
visit_children(node, source, ctx, result);
}
}
}
fn visit_children(node: Node, source: &str, ctx: &VisitContext<'_>, result: &mut ExtractResult) {
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
visit_node(child, source, ctx, result);
}
}
}
fn is_fixed_form_fortran(file_path: &str) -> bool {
let ext = file_path.rsplit('.').next().unwrap_or("");
ext.eq_ignore_ascii_case("f")
}
fn preprocess_fixed_form_comments(source: &str, file_path: &str) -> String {
if !is_fixed_form_fortran(file_path) {
return source.to_string();
}
let mut out = String::with_capacity(source.len());
for line in source.split_inclusive('\n') {
if let Some(first) = line.bytes().next() {
if first == b'*' || first == b'C' || first == b'c' {
out.push('!');
out.push_str(&line[1..]);
continue;
}
}
out.push_str(line);
}
out
}
fn collect_declared_arrays(root: Node, source: &str) -> HashSet<String> {
let mut arrays = HashSet::new();
collect_arrays_recursive(root, source, &mut arrays);
arrays
}
fn collect_arrays_recursive(node: Node, source: &str, arrays: &mut HashSet<String>) {
if node.kind() == "sized_declarator" {
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
if child.kind() == "identifier" {
if let Some(name) = node_text(child, source) {
arrays.insert(name.to_string());
}
}
}
}
}
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
collect_arrays_recursive(child, source, arrays);
}
}
}
fn first_identifier_child<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
if child.kind() == "identifier" {
return node_text(child, source);
}
}
}
None
}
fn extract_module(node: Node, source: &str, ctx: &VisitContext<'_>, result: &mut ExtractResult) {
let Some(name) = statement_name(node, "module_statement", source) else {
return;
};
let line = node.start_position().row as u32 + 1;
let qn = dedupe_qn(
make_qn(ctx.file_path, &name, ctx.project, None),
line,
result,
);
let model_node = ModelNode::builder(NodeLabel::Module, name, qn)
.file_path(ctx.file_path)
.start_line(node.start_position().row as u32 + 1)
.end_line(node.end_position().row as u32 + 1)
.language(Language::Fortran)
.project(ctx.project)
.is_exported(true)
.is_global(true)
.build();
add_definition_edges(ctx.file_path, ctx.project, &model_node, result);
result.push_node(model_node);
}
fn extract_subroutine_or_function(
node: Node,
source: &str,
ctx: &VisitContext<'_>,
result: &mut ExtractResult,
statement_kind: &str,
) {
let Some(name) = statement_name(node, statement_kind, source) else {
return;
};
let line = node.start_position().row as u32 + 1;
let qn = dedupe_qn(
make_qn(ctx.file_path, &name, ctx.project, ctx.current_parent),
line,
result,
);
let signature = node_text(node, source).map(String::from);
let mut builder = ModelNode::builder(NodeLabel::Function, name, qn)
.file_path(ctx.file_path)
.start_line(node.start_position().row as u32 + 1)
.end_line(node.end_position().row as u32 + 1)
.language(Language::Fortran)
.project(ctx.project)
.is_exported(true)
.is_global(true);
if let Some(sig) = signature {
builder = builder.signature(sig);
}
let model_node = builder.build();
add_definition_edges(ctx.file_path, ctx.project, &model_node, result);
result.push_node(model_node);
}
fn extract_program(node: Node, source: &str, ctx: &VisitContext<'_>, result: &mut ExtractResult) {
let Some(name) = statement_name(node, "program_statement", source) else {
return;
};
let line = node.start_position().row as u32 + 1;
let qn = dedupe_qn(
make_qn(ctx.file_path, &name, ctx.project, ctx.current_parent),
line,
result,
);
let model_node = ModelNode::builder(NodeLabel::Function, name, qn)
.file_path(ctx.file_path)
.start_line(node.start_position().row as u32 + 1)
.end_line(node.end_position().row as u32 + 1)
.language(Language::Fortran)
.project(ctx.project)
.is_exported(true)
.is_global(true)
.build();
add_definition_edges(ctx.file_path, ctx.project, &model_node, result);
result.push_node(model_node);
}
fn extract_bind_c(
node: Node,
source: &str,
_ctx: &VisitContext<'_>,
result: &mut ExtractResult,
statement_kind: &str,
) {
let Some(stmt) = find_child(node, source, statement_kind) else {
return;
};
let Some(binding) = find_child(stmt, source, "language_binding") else {
return;
};
let binds_c = (0..binding.named_child_count() as u32)
.filter_map(|i| binding.named_child(i))
.any(|c| c.kind() == "identifier" && node_text(c, source) == Some("C"));
if !binds_c {
return;
}
let Some(symbol_name) = statement_name(node, statement_kind, source) else {
return;
};
let mut c_name = symbol_name.clone();
for i in 0..binding.named_child_count() as u32 {
if let Some(kw) = binding.named_child(i) {
if kw.kind() == "keyword_argument" {
let mut is_name_kw = false;
let mut alias = None;
for j in 0..kw.named_child_count() as u32 {
if let Some(child) = kw.named_child(j) {
match child.kind() {
"identifier" if node_text(child, source) == Some("name") => {
is_name_kw = true;
}
"string_literal" => {
alias = node_text(child, source).map(String::from);
}
_ => {}
}
}
}
if is_name_kw {
if let Some(a) = alias {
c_name = a.trim_matches('"').to_string();
}
break;
}
}
}
}
let line = node.start_position().row as u32 + 1;
result.externs.push(ExternInfo {
language: Language::C,
names: vec![c_name],
line,
signature: Some(symbol_name),
});
}
fn find_child<'a>(node: Node<'a>, _source: &'a str, kind: &str) -> Option<Node<'a>> {
(0..node.named_child_count() as u32)
.filter_map(|i| node.named_child(i))
.find(|c| c.kind() == kind)
}
fn extract_use(node: Node, source: &str, result: &mut ExtractResult) {
let mut module_name = None;
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
if child.kind() == "module_name" {
module_name = node_text(child, source).map(String::from);
break;
}
}
}
let Some(name) = module_name else {
return;
};
let line = node.start_position().row as u32 + 1;
result.imports.push(ImportInfo {
source_file: name,
imported_names: Vec::new(),
line,
});
}
fn extract_call(node: Node, source: &str, ctx: &VisitContext<'_>, result: &mut ExtractResult) {
let mut callee = None;
let mut args = Vec::new();
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
match child.kind() {
"identifier" => {
if callee.is_none() {
callee = node_text(child, source).map(String::from);
}
}
"argument_list" => {
for j in 0..child.named_child_count() as u32 {
if let Some(arg) = child.named_child(j) {
if let Ok(text) = arg.utf8_text(source.as_bytes()) {
args.push(text.to_string());
}
}
}
}
_ => {}
}
}
}
let Some(callee) = callee else {
return;
};
let caller_qn = ctx
.current_func
.map(|name| make_qn(ctx.file_path, name, ctx.project, ctx.current_parent));
result.calls.push(CallInfo {
caller_qn,
callee_name: callee,
line: node.start_position().row as u32 + 1,
args,
});
}
fn statement_name(node: Node, statement_kind: &str, source: &str) -> Option<String> {
for i in 0..node.named_child_count() as u32 {
if let Some(child) = node.named_child(i) {
if child.kind() == statement_kind {
if let Some(name_node) = child.child_by_field_name("name") {
if let Some(text) = node_text(name_node, source) {
return Some(text.to_string());
}
}
for j in 0..child.named_child_count() as u32 {
if let Some(grandchild) = child.named_child(j) {
if grandchild.kind() == "name" || grandchild.kind() == "identifier" {
if let Some(text) = node_text(grandchild, source) {
return Some(text.to_string());
}
}
}
}
}
}
}
None
}
fn node_text<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> {
node.utf8_text(source.as_bytes()).ok()
}
fn identifier_text(node: Node, source: &str) -> Option<String> {
if node.kind() == "identifier" {
node_text(node, source).map(String::from)
} else {
None
}
}
fn is_fortran_read_position(node: Node) -> bool {
let Some(parent) = node.parent() else {
return false;
};
match parent.kind() {
"math_expression"
| "relational_expression"
| "parenthesized_expression"
| "return_statement"
| "argument_list"
| "subscript_expression"
| "conditional_expression" => true,
"subroutine_call" | "call_statement" => false,
"assignment_statement" => !is_at_field(node, parent, "left"),
"field_expression" => false,
_ => false,
}
}
fn is_at_field(node: Node, parent: Node, field: &str) -> bool {
parent
.child_by_field_name(field)
.is_some_and(|f| f.byte_range() == node.byte_range())
}
fn do_loop_variable(node: Node, source: &str) -> Option<String> {
let do_statement = find_child(node, source, "do_statement")?;
let loop_control = find_child(do_statement, source, "loop_control_expression")?;
for i in 0..loop_control.named_child_count() as u32 {
if let Some(child) = loop_control.named_child(i) {
if child.kind() == "identifier" {
return node_text(child, source).map(String::from);
}
}
}
None
}
fn make_qn(file_path: &str, name: &str, project: &str, parent: Option<&str>) -> String {
FqnGenerator::generate(project, file_path, name, Language::Fortran, parent)
}
fn combine_scope(parent: Option<&str>, child: Option<&str>) -> Option<String> {
match (parent, child) {
(Some(p), Some(c)) => Some(format!("{p}_{c}")),
(None, Some(c)) => Some(c.to_string()),
(Some(p), None) => Some(p.to_string()),
(None, None) => None,
}
}
fn add_definition_edges(
file_path: &str,
project: &str,
node: &ModelNode,
result: &mut ExtractResult,
) {
result.edges.push(Edge::new(
file_path.to_string(),
node.id.clone(),
EdgeType::Defines,
project,
));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::NodeLabel;
const FORTRAN_SOURCE: &str = r#"module mymod
use iso_c_binding
contains
subroutine my_sub(a, b)
integer, intent(in) :: a
integer, intent(out) :: b
b = a + 1
end subroutine
function my_func(x) result(y)
integer, intent(in) :: x
integer :: y
y = x * 2
end function
end module
program main
use mymod
integer :: a, b
call my_sub(1, b)
end program
"#;
fn extract(source: &str) -> ExtractResult {
let ext = FortranExtractor::new();
ext.extract(source, "test.f90", "proj")
.expect("extraction should succeed")
}
#[test]
fn language_returns_fortran() {
assert_eq!(FortranExtractor::new().language(), Language::Fortran);
}
#[test]
fn default_creates_extractor() {
let ext = FortranExtractor::default();
assert_eq!(ext.language(), Language::Fortran);
}
#[test]
fn extracts_module() {
let result = extract(FORTRAN_SOURCE);
let modules: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Module)
.collect();
assert_eq!(modules.len(), 1, "should extract 1 module");
assert_eq!(modules[0].name, "mymod");
assert_eq!(modules[0].language, Some(Language::Fortran));
assert_eq!(modules[0].project, "proj");
assert_eq!(modules[0].file_path.as_deref(), Some("test.f90"));
}
#[test]
fn extracts_subroutine() {
let result = extract(FORTRAN_SOURCE);
let funcs: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Function)
.collect();
let names: Vec<_> = funcs.iter().map(|n| n.name.as_str()).collect();
assert!(
names.contains(&"my_sub"),
"should extract my_sub subroutine: {:?}",
names
);
}
#[test]
fn extracts_function() {
let result = extract(FORTRAN_SOURCE);
let funcs: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Function)
.collect();
let names: Vec<_> = funcs.iter().map(|n| n.name.as_str()).collect();
assert!(
names.contains(&"my_func"),
"should extract my_func function: {:?}",
names
);
}
#[test]
fn extracts_program() {
let result = extract(FORTRAN_SOURCE);
let funcs: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Function)
.collect();
let names: Vec<_> = funcs.iter().map(|n| n.name.as_str()).collect();
assert!(
names.contains(&"main"),
"should extract main program: {:?}",
names
);
}
#[test]
fn extracts_use_statements() {
let result = extract(FORTRAN_SOURCE);
assert_eq!(result.imports.len(), 2, "should extract 2 use statements");
let sources: Vec<_> = result
.imports
.iter()
.map(|i| i.source_file.as_str())
.collect();
assert!(sources.contains(&"iso_c_binding"));
assert!(sources.contains(&"mymod"));
}
#[test]
fn extracts_call_to_my_sub() {
let result = extract(FORTRAN_SOURCE);
let callees: Vec<_> = result
.calls
.iter()
.map(|c| c.callee_name.as_str())
.collect();
assert!(
callees.contains(&"my_sub"),
"should extract call to my_sub: {:?}",
callees
);
}
#[test]
fn call_has_line_and_args() {
let result = extract(FORTRAN_SOURCE);
let call = result
.calls
.iter()
.find(|c| c.callee_name == "my_sub")
.expect("call to my_sub should exist");
assert_eq!(call.line, 19);
assert_eq!(call.args.len(), 2, "my_sub(1, b) should have 2 args");
}
#[test]
fn iso_c_binding_use_does_not_create_empty_extern() {
let result = extract(FORTRAN_SOURCE);
assert!(
result.externs.is_empty(),
"use iso_c_binding without bind(C) should not create extern"
);
}
#[test]
fn extract_bind_c_collects_symbol_name_with_alias() {
let src = r#"subroutine my_func(a) bind(C, name="my_func_c")
use iso_c_binding
integer(c_int), value :: a
end subroutine"#;
let result = extract(src);
assert_eq!(result.externs.len(), 1, "should detect 1 bind(C) FFI");
let ext = &result.externs[0];
assert_eq!(ext.language, Language::C);
assert_eq!(ext.names, vec!["my_func_c"], "should use the C alias");
assert_eq!(ext.signature.as_deref(), Some("my_func"));
}
#[test]
fn extract_bind_c_without_name_uses_fortran_name() {
let src = r#"subroutine my_func(a) bind(C)
use iso_c_binding
integer(c_int), value :: a
end subroutine"#;
let result = extract(src);
assert_eq!(result.externs.len(), 1, "should detect 1 bind(C) FFI");
let ext = &result.externs[0];
assert_eq!(ext.names, vec!["my_func"], "should use the Fortran name");
}
#[test]
fn extract_bind_c_skips_non_bind_c_subroutines() {
let src = "subroutine my_func(a)\n integer :: a\nend subroutine\n";
let result = extract(src);
assert!(
result.externs.is_empty(),
"non-bind(C) should not create extern"
);
}
#[test]
fn extract_bind_c_works_for_functions() {
let src = r#"function my_func(x) bind(C, name="my_func_c") result(y)
use iso_c_binding
integer(c_int), value :: x
integer(c_int) :: y
end function"#;
let result = extract(src);
assert_eq!(
result.externs.len(),
1,
"should detect 1 bind(C) FFI on function"
);
let ext = &result.externs[0];
assert_eq!(ext.names, vec!["my_func_c"]);
}
#[test]
fn creates_defines_edges() {
let result = extract(FORTRAN_SOURCE);
let defines_count = result
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::Defines)
.count();
let node_count = result.nodes.len();
assert_eq!(defines_count, node_count);
let contains_count = result
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::Contains)
.count();
assert_eq!(
contains_count, 0,
"B1 fix: no CONTAINS edges should be emitted"
);
}
#[test]
fn qualified_name_uses_file_path_and_name() {
let result = extract(FORTRAN_SOURCE);
let mymod = result.nodes.iter().find(|n| n.name == "mymod").unwrap();
assert_eq!(mymod.qualified_name, "proj.test.f90.mymod");
}
#[test]
fn empty_source_returns_empty_result() {
let result = extract("");
assert!(result.is_empty());
}
#[test]
fn subroutine_has_signature() {
let result = extract(FORTRAN_SOURCE);
let my_sub = result.nodes.iter().find(|n| n.name == "my_sub").unwrap();
assert!(
my_sub.signature.is_some(),
"subroutine should have a signature"
);
assert!(my_sub.signature.as_deref().unwrap().contains("my_sub"));
}
#[test]
fn result_language_is_fortran() {
let result = extract(FORTRAN_SOURCE);
assert_eq!(result.language, Language::Fortran);
assert_eq!(result.file_path, "test.f90");
}
#[test]
fn handles_standalone_subroutine() {
let src = "subroutine foo(a)\n integer :: a\nend subroutine\n";
let result = extract(src);
let funcs: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Function)
.collect();
assert_eq!(funcs.len(), 1);
assert_eq!(funcs[0].name, "foo");
}
#[test]
fn handles_standalone_function() {
let src = "function bar(x) result(y)\n integer :: x, y\n y = x\nend function\n";
let result = extract(src);
let funcs: Vec<_> = result
.nodes
.iter()
.filter(|n| n.label == NodeLabel::Function)
.collect();
assert_eq!(funcs.len(), 1);
assert_eq!(funcs[0].name, "bar");
}
#[test]
fn use_without_iso_c_binding_no_extern() {
let src = "program p\n use other_mod\nend program\n";
let result = extract(src);
assert!(
result.externs.is_empty(),
"non-iso_c_binding use should not create extern"
);
}
#[test]
fn call_in_function_has_dotted_fqn_caller_qn() {
let src = "subroutine caller()\n call callee()\nend subroutine\n";
let ext = FortranExtractor::new();
let result = ext
.extract(src, "/tmp/demo/main.f90", "proj")
.expect("extraction should succeed");
let call = result
.calls
.iter()
.find(|c| c.callee_name == "callee")
.expect("should find call to callee");
assert_eq!(
call.caller_qn.as_deref(),
Some("proj.tmp.demo.main.f90.caller"),
"caller_qn should be the dotted FQN of the enclosing subroutine"
);
let caller_node = result
.nodes
.iter()
.find(|n| n.name == "caller")
.expect("should find caller subroutine node");
assert_eq!(
call.caller_qn.as_deref(),
Some(caller_node.qualified_name.as_str()),
"caller_qn must match the caller subroutine node id"
);
}
#[test]
fn call_in_program_has_program_caller_qn() {
let src = "program main\n call callee()\nend program\n";
let ext = FortranExtractor::new();
let result = ext
.extract(src, "main.f90", "proj")
.expect("extraction should succeed");
let call = result
.calls
.iter()
.find(|c| c.callee_name == "callee")
.expect("should find call to callee inside program");
let program_node = result
.nodes
.iter()
.find(|n| n.name == "main")
.expect("should find program main node");
assert_eq!(
call.caller_qn.as_deref(),
Some(program_node.qualified_name.as_str()),
"call inside program should have caller_qn matching the program's FQN"
);
assert_eq!(
call.caller_qn.as_deref(),
Some("proj.main.f90.main"),
"caller_qn should be the dotted FQN of the enclosing program"
);
}
#[test]
fn nested_subroutine_duplicate_name_disambiguated() {
let src = r#" SUBROUTINE dfi_array_reset(grid)
INTEGER :: grid
END SUBROUTINE dfi_array_reset
RECURSIVE SUBROUTINE dfi_array_reset_recurse(grid)
INTERFACE
SUBROUTINE dfi_array_reset(grid)
INTEGER :: grid
END SUBROUTINE dfi_array_reset
END INTERFACE
END SUBROUTINE dfi_array_reset_recurse
"#;
let result = extract(src);
let resets: Vec<_> = result
.nodes
.iter()
.filter(|n| n.name == "dfi_array_reset")
.collect();
assert_eq!(
resets.len(),
2,
"should extract 2 dfi_array_reset subroutines, got: {:?}",
result.nodes.iter().map(|n| &n.name).collect::<Vec<_>>()
);
assert_ne!(
resets[0].qualified_name, resets[1].qualified_name,
"FQNs must be unique: {} vs {}",
resets[0].qualified_name, resets[1].qualified_name
);
assert!(
!resets[0].qualified_name.contains('#'),
"first occurrence should have no disambiguator: {}",
resets[0].qualified_name
);
assert!(
resets[1].qualified_name.contains("#L"),
"duplicate should be disambiguated with #L: {}",
resets[1].qualified_name
);
}
#[test]
fn read_in_subroutine_has_dotted_fqn_reader_qn() {
let src = "subroutine caller(x)\n integer, intent(in) :: x\n integer :: y\n y = x + 1\nend subroutine\n";
let ext = FortranExtractor::new();
let result = ext
.extract(src, "/tmp/demo/main.f90", "proj")
.expect("extraction should succeed");
let read = result
.reads
.iter()
.find(|r| r.var_name == "x")
.expect("should find a read of x");
assert_eq!(
read.reader_qn.as_deref(),
Some("proj.tmp.demo.main.f90.caller"),
"reader_qn should be the dotted FQN of the enclosing subroutine"
);
let caller_node = result
.nodes
.iter()
.find(|n| n.name == "caller")
.expect("should find caller subroutine node");
assert_eq!(
read.reader_qn.as_deref(),
Some(caller_node.qualified_name.as_str()),
"reader_qn must match the caller subroutine node id"
);
}
#[test]
fn write_in_subroutine_assignment_has_dotted_fqn_writer_qn() {
let src = "subroutine caller(x)\n integer, intent(in) :: x\n integer :: y\n y = x + 1\nend subroutine\n";
let ext = FortranExtractor::new();
let result = ext
.extract(src, "/tmp/demo/main.f90", "proj")
.expect("extraction should succeed");
let write = result
.writes
.iter()
.find(|w| w.var_name == "y")
.expect("should find a write of y");
assert_eq!(
write.writer_qn.as_deref(),
Some("proj.tmp.demo.main.f90.caller"),
"writer_qn should be the dotted FQN of the enclosing subroutine"
);
let caller_node = result
.nodes
.iter()
.find(|n| n.name == "caller")
.expect("should find caller subroutine node");
assert_eq!(
write.writer_qn.as_deref(),
Some(caller_node.qualified_name.as_str()),
"writer_qn must match the caller subroutine node id"
);
}
#[test]
fn do_loop_variable_is_captured_as_write() {
let src = "subroutine looper()\n integer :: i, s\n do i = 1, 10\n s = s + i\n end do\nend subroutine\n";
let ext = FortranExtractor::new();
let result = ext
.extract(src, "/tmp/demo/main.f90", "proj")
.expect("extraction should succeed");
let i_write = result
.writes
.iter()
.find(|w| w.var_name == "i")
.expect("should find a write of loop variable i");
assert_eq!(
i_write.writer_qn.as_deref(),
Some("proj.tmp.demo.main.f90.looper"),
"loop variable writer_qn should be the dotted FQN of the enclosing subroutine"
);
assert!(
result.writes.iter().any(|w| w.var_name == "s"),
"loop body assignment should write s"
);
assert!(
result.reads.iter().any(|r| r.var_name == "i"),
"loop body should read i"
);
}
#[test]
fn module_level_declaration_no_reads_or_writes() {
let src = "module m\n integer :: g\nend module\n";
let ext = FortranExtractor::new();
let result = ext
.extract(src, "/tmp/demo/main.f90", "proj")
.expect("extraction should succeed");
assert!(
result.reads.is_empty(),
"module-level declaration must not produce ReadInfo: {:?}",
result.reads
);
assert!(
result.writes.is_empty(),
"module-level declaration must not produce WriteInfo: {:?}",
result.writes
);
}
#[test]
fn function_call_in_expression_is_extracted() {
let src = r#" SUBROUTINE TEST()
REAL :: EPS
EPS = SLAMCH('Epsilon')
END SUBROUTINE
"#;
let result = extract(src);
let calls: Vec<_> = result
.calls
.iter()
.map(|c| c.callee_name.as_str())
.collect();
assert!(
calls.contains(&"SLAMCH"),
"B10 fix: function call SLAMCH should be extracted: {:?}",
calls
);
}
#[test]
fn array_access_not_extracted_as_call() {
let src = r#" SUBROUTINE TEST()
REAL :: D(10)
INTEGER :: I, X
X = D(I)
END SUBROUTINE
"#;
let result = extract(src);
let calls: Vec<_> = result
.calls
.iter()
.map(|c| c.callee_name.as_str())
.collect();
assert!(
!calls.contains(&"D"),
"B10 fix: array access D(I) must NOT be extracted as call: {:?}",
calls
);
}
#[test]
fn nested_function_call_in_array_index_is_captured() {
let src = r#" SUBROUTINE TEST()
REAL :: D(10)
INTEGER :: I, X
X = D(MYFUNC(I))
END SUBROUTINE
"#;
let result = extract(src);
let calls: Vec<_> = result
.calls
.iter()
.map(|c| c.callee_name.as_str())
.collect();
assert!(
!calls.contains(&"D"),
"array access D(...) should not be a call: {:?}",
calls
);
assert!(
calls.contains(&"MYFUNC"),
"nested function call MYFUNC should be captured: {:?}",
calls
);
}
#[test]
fn multiple_function_calls_in_expression() {
let src = r#" SUBROUTINE TEST()
REAL :: X, Y, Z, EPS
Z = MAX(X, MIN(Y, EPS))
END SUBROUTINE
"#;
let result = extract(src);
let calls: Vec<_> = result
.calls
.iter()
.map(|c| c.callee_name.as_str())
.collect();
assert!(
calls.contains(&"MAX"),
"MAX should be captured: {:?}",
calls
);
assert!(
calls.contains(&"MIN"),
"MIN should be captured: {:?}",
calls
);
}
#[test]
fn function_call_with_array_arg_not_double_counted() {
let src = r#" SUBROUTINE TEST()
REAL :: D(10)
INTEGER :: I
REAL :: X
X = ABS(D(I))
END SUBROUTINE
"#;
let result = extract(src);
let calls: Vec<_> = result
.calls
.iter()
.map(|c| c.callee_name.as_str())
.collect();
assert!(
calls.contains(&"ABS"),
"ABS function call should be captured: {:?}",
calls
);
assert!(
!calls.contains(&"D"),
"array access D(I) should NOT be captured: {:?}",
calls
);
assert_eq!(
result.calls.len(),
1,
"should have exactly 1 call (ABS only): {:?}",
calls
);
}
#[test]
fn is_fixed_form_detects_f_extension() {
assert!(is_fixed_form_fortran("foo.f"));
assert!(is_fixed_form_fortran("foo.F"));
assert!(is_fixed_form_fortran("src/bar.f"));
assert!(!is_fixed_form_fortran("foo.f90"));
assert!(!is_fixed_form_fortran("foo.f95"));
assert!(!is_fixed_form_fortran("foo.rs"));
}
#[test]
fn preprocess_converts_star_comments_to_bang() {
let src = "* This is a comment\n SUBROUTINE FOO()\n END\n";
let out = preprocess_fixed_form_comments(src, "test.f");
assert!(out.starts_with("! This is a comment"), "got: {out:?}");
assert!(out.contains("SUBROUTINE FOO"));
}
#[test]
fn preprocess_converts_uppercase_c_comments() {
let src = "C This is a comment\n SUBROUTINE FOO()\n END\n";
let out = preprocess_fixed_form_comments(src, "test.f");
assert!(out.starts_with("! This is a comment"), "got: {out:?}");
}
#[test]
fn preprocess_converts_lowercase_c_comments() {
let src = "c This is a comment\n SUBROUTINE FOO()\n END\n";
let out = preprocess_fixed_form_comments(src, "test.f");
assert!(out.starts_with("! This is a comment"), "got: {out:?}");
}
#[test]
fn preprocess_preserves_byte_offsets() {
let src = "* comment\n X = 1\n";
let out = preprocess_fixed_form_comments(src, "test.f");
assert_eq!(src.len(), out.len());
assert_eq!(out.as_bytes()[0], b'!');
assert_eq!(
out.find("X = 1"),
src.find("X = 1"),
"byte offset of code must be preserved"
);
}
#[test]
fn preprocess_leaves_free_form_unchanged() {
let src = "! comment\nsubroutine foo()\nend subroutine\n";
let out = preprocess_fixed_form_comments(src, "test.f90");
assert_eq!(src, out.as_str());
}
#[test]
fn extract_finds_subroutine_in_fixed_form_file() {
let src = "* SUBROUTINE XERBLA( SRNAME, INFO )\n*\n SUBROUTINE XERBLA( SRNAME, INFO )\n CHARACTER*(*) SRNAME\n INTEGER INFO\n WRITE(*,FMT=9999) SRNAME, INFO\n STOP\n 9999 FORMAT(' error')\n END\n";
let ext = FortranExtractor::new();
let result = ext
.extract(src, "xerbla.f", "proj")
.expect("extraction should succeed");
let has_xerbla = result
.nodes
.iter()
.any(|n| n.name == "XERBLA" && n.label == NodeLabel::Function);
assert!(
has_xerbla,
"XERBLA subroutine node should be extracted from fixed-form file; got nodes: {:?}",
result.nodes.iter().map(|n| &n.name).collect::<Vec<_>>()
);
}
#[test]
fn do_while_loop_does_not_produce_loop_variable_write() {
let src = "subroutine looper()\n integer :: i\n do while (i < 10)\n i = i + 1\n end do\nend subroutine\n";
let result = extract(src);
let i_writes: Vec<_> = result.writes.iter().filter(|w| w.var_name == "i").collect();
assert_eq!(
i_writes.len(),
1,
"do while should not produce a loop variable write, only assignment write: {:?}",
result.writes
);
}
#[test]
fn field_expression_identifier_not_read() {
let src =
"subroutine test()\n integer :: obj, val\n val = obj%field\nend subroutine\n";
let result = extract(src);
let reads: Vec<_> = result.reads.iter().map(|r| r.var_name.as_str()).collect();
assert!(
!reads.contains(&"field"),
"field_expression field identifier should not be a read: {:?}",
reads
);
}
#[test]
fn combine_scope_all_branches() {
assert_eq!(
combine_scope(Some("parent"), Some("child")),
Some("parent_child".to_string())
);
assert_eq!(
combine_scope(None, Some("child")),
Some("child".to_string())
);
assert_eq!(
combine_scope(Some("parent"), None),
Some("parent".to_string())
);
assert_eq!(combine_scope(None, None), None);
}
#[test]
fn parse_error_on_invalid_source() {
let result = extract("this is not fortran at all !!!");
assert!(
result.nodes.is_empty(),
"invalid source should produce no nodes: {:?}",
result.nodes
);
}
}