backhopper-xref-reader 0.37.0

Erlang source to call-graph reader for backhopper-xref
Documentation
// Copyright (C) 2026 Michael S. Klishin and Contributors
// SPDX-License-Identifier: Apache-2.0 OR MIT
// See LICENSE-APACHE and LICENSE-MIT for details.

use std::path::PathBuf;

use backhopper_xref_graph::{CallTarget, FunctionRef};
use backhopper_xref_reader::{ReadWarning, SourceReader};

fn external_names(modules: &[backhopper_xref_reader::ModuleData]) -> Vec<String> {
    modules
        .iter()
        .flat_map(|m| m.external_calls.iter())
        .filter_map(|c| match &c.callee {
            CallTarget::External(FunctionRef::Concrete(mfa)) => {
                Some(format!("{}:{}/{}", mfa.module, mfa.function, mfa.arity))
            }
            _ => None,
        })
        .collect()
}

#[test]
fn macros_from_relative_include_are_available_in_the_consumer() {
    let files = vec![
        (
            PathBuf::from("deps/rabbit/src/m.erl"),
            b"-module(m).\n\
              -include(\"shared.hrl\").\n\
              -export([go/0]).\n\
              go() -> ?SERVER:start(1).\n"
                .to_vec(),
        ),
        (
            PathBuf::from("deps/rabbit/src/shared.hrl"),
            b"-define(SERVER, my_module).\n".to_vec(),
        ),
    ];
    let reader = SourceReader::new();
    let out = reader.read_tree(files).expect("read_tree ok");
    let names = external_names(&out.modules);
    assert!(names.iter().any(|n| n == "my_module:start/1"), "{names:?}");
}

#[test]
fn macros_from_include_lib_resolve_through_app_root() {
    let files = vec![
        (
            PathBuf::from("deps/consumer/src/m.erl"),
            b"-module(m).\n\
              -include_lib(\"shared/include/api.hrl\").\n\
              -export([go/0]).\n\
              go() -> ?LOG(debug, \"hi\").\n"
                .to_vec(),
        ),
        (
            PathBuf::from("deps/shared/include/api.hrl"),
            b"-define(LOG(L, M), logger:log(L, M)).\n".to_vec(),
        ),
    ];
    let reader = SourceReader::new();
    let out = reader.read_tree(files).expect("read_tree ok");
    let names = external_names(&out.modules);
    assert!(names.iter().any(|n| n == "logger:log/2"), "{names:?}");
}

#[test]
fn missing_include_emits_warning_but_does_not_panic() {
    let files = vec![(
        PathBuf::from("deps/rabbit/src/m.erl"),
        b"-module(m).\n\
          -include(\"nonexistent.hrl\").\n\
          -export([go/0]).\n\
          go() -> ok.\n"
            .to_vec(),
    )];
    let reader = SourceReader::new();
    let out = reader.read_tree(files).expect("read_tree ok");
    assert!(
        out.warnings.iter().any(|w| matches!(
            w,
            ReadWarning::UnresolvedInclude { target, .. }
                if target.to_string_lossy() == "nonexistent.hrl"
        )),
        "warnings={:?}",
        out.warnings
    );
}

#[test]
fn nested_include_from_a_header_pulls_in_transitive_macros() {
    let files = vec![
        (
            PathBuf::from("deps/rabbit/src/m.erl"),
            b"-module(m).\n\
              -include(\"a.hrl\").\n\
              -export([go/0]).\n\
              go() -> ?SERVER:start(1).\n"
                .to_vec(),
        ),
        (
            PathBuf::from("deps/rabbit/src/a.hrl"),
            b"-include(\"b.hrl\").\n".to_vec(),
        ),
        (
            PathBuf::from("deps/rabbit/src/b.hrl"),
            b"-define(SERVER, my_module).\n".to_vec(),
        ),
    ];
    let reader = SourceReader::new();
    let out = reader.read_tree(files).expect("read_tree ok");
    let names = external_names(&out.modules);
    assert!(
        names.iter().any(|n| n == "my_module:start/1"),
        "transitive include did not resolve: {names:?}"
    );
}

#[test]
fn deeply_nested_includes_resolve_to_arbitrary_depth() {
    let files = vec![
        (
            PathBuf::from("deps/rabbit/src/m.erl"),
            b"-module(m).\n\
              -include(\"a.hrl\").\n\
              -export([go/0]).\n\
              go() -> ?SERVER:start(1).\n"
                .to_vec(),
        ),
        (
            PathBuf::from("deps/rabbit/src/a.hrl"),
            b"-include(\"b.hrl\").\n".to_vec(),
        ),
        (
            PathBuf::from("deps/rabbit/src/b.hrl"),
            b"-include(\"c.hrl\").\n".to_vec(),
        ),
        (
            PathBuf::from("deps/rabbit/src/c.hrl"),
            b"-include(\"d.hrl\").\n".to_vec(),
        ),
        (
            PathBuf::from("deps/rabbit/src/d.hrl"),
            b"-define(SERVER, deep_module).\n".to_vec(),
        ),
    ];
    let reader = SourceReader::new();
    let out = reader.read_tree(files).expect("read_tree ok");
    let names = external_names(&out.modules);
    assert!(
        names.iter().any(|n| n == "deep_module:start/1"),
        "4-level include chain: {names:?}"
    );
}

#[test]
fn include_cycle_does_not_infinite_loop() {
    let files = vec![
        (
            PathBuf::from("deps/rabbit/src/m.erl"),
            b"-module(m).\n\
              -include(\"a.hrl\").\n\
              -export([go/0]).\n\
              go() -> ?A:f().\n"
                .to_vec(),
        ),
        (
            PathBuf::from("deps/rabbit/src/a.hrl"),
            b"-include(\"b.hrl\").\n\
              -define(A, alpha).\n"
                .to_vec(),
        ),
        (
            PathBuf::from("deps/rabbit/src/b.hrl"),
            b"-include(\"a.hrl\").\n".to_vec(),
        ),
    ];
    let reader = SourceReader::new();
    let out = reader.read_tree(files).expect("read_tree ok");
    let names = external_names(&out.modules);
    assert!(
        names.iter().any(|n| n == "alpha:f/0"),
        "cycle still resolves macros from first visit: {names:?}"
    );
}

#[test]
fn include_basename_falls_back_when_path_is_relative() {
    let files = vec![
        (
            PathBuf::from("deps/rabbit/src/m.erl"),
            b"-module(m).\n\
              -include(\"../include/shared.hrl\").\n\
              -export([go/0]).\n\
              go() -> ?SERVER:start(1).\n"
                .to_vec(),
        ),
        (
            PathBuf::from("deps/rabbit/include/shared.hrl"),
            b"-define(SERVER, my_module).\n".to_vec(),
        ),
    ];
    let reader = SourceReader::new();
    let out = reader.read_tree(files).expect("read_tree ok");
    let names = external_names(&out.modules);
    assert!(names.iter().any(|n| n == "my_module:start/1"), "{names:?}");
}