-module(r3lfe_dependency_scanner). %% API exports -export([ scan_file/2, scan_file/3, scan_file/4, scan_content/1, resolve_include/2, resolve_include/3 ]). %% For testing -export([ parse_include_forms/1, extract_include_path/1, classify_include/1 ]). -include("r3lfe.hrl"). -type include_type() :: include_file | include_lib. -type include_form() :: {include_type(), string()}. -export_type([include_type/0, include_form/0]). %%==================================================================== %% API functions %%==================================================================== %% @doc Scan an LFE source file for dependencies %% Returns list of absolute paths to header files this source depends on -spec scan_file(file:filename(), rebar_app_info:t()) -> [file:filename()]. scan_file(SourceFile, AppInfo) -> scan_file(SourceFile, AppInfo, #{}). %% @doc Scan with options %% Options: %% - cache => true/false (future: cache parsed results) %% - include_dirs => [Dir] (additional include directories) -spec scan_file(file:filename(), rebar_app_info:t(), map()) -> [file:filename()]. scan_file(SourceFile, AppInfo, Opts) when is_map(Opts) -> %% Extract AppDir and IncludeDirs from AppInfo AppDir = rebar_app_info:dir(AppInfo), IncludeDirs = maps:get(include_dirs, Opts, r3lfe_config:get_include_dirs(AppInfo)), %% Call common helper do_scan_file(SourceFile, AppDir, IncludeDirs, Opts). %% @doc Scan file with explicit app directory and include directories %% This variant doesn't require an AppInfo record -spec scan_file(file:filename(), file:filename(), [file:filename()], map()) -> [file:filename()]. scan_file(SourceFile, AppDir, IncludeDirs, Opts) -> %% Call common helper do_scan_file(SourceFile, AppDir, IncludeDirs, Opts). %% @doc Scan content string for include forms -spec scan_content(string()) -> [include_form()]. scan_content(Content) -> Forms = parse_include_forms(Content), lists:filtermap( fun(RawForm) -> case extract_include_path(RawForm) of {ok, Type, Path} -> {true, {Type, Path}}; error -> false end end, Forms ). %% @doc Resolve an include form to an absolute path -spec resolve_include(include_form(), file:filename()) -> {ok, file:filename()} | {error, term()}. resolve_include(Form, AppDir) -> IncludeDir = filename:join(AppDir, ?DEFAULT_INCLUDE_DIR), resolve_include(Form, AppDir, [IncludeDir]). -spec resolve_include(include_form(), file:filename(), [file:filename()]) -> {ok, file:filename()} | {error, term()}. resolve_include({include_file, Path}, AppDir, IncludeDirs) -> resolve_include_file(Path, AppDir, IncludeDirs); resolve_include({include_lib, Path}, AppDir, IncludeDirs) -> %% Path-first search (mirrors lfe_macro_include:lib/3 and epp): %% try the include dirs and the parent of the app dir before %% falling back to code:lib_dir/1, so self-references resolve %% before the app is staged in _build. SearchDirs = IncludeDirs ++ [filename:dirname(AppDir)], Candidates = [filename:join(Dir, Path) || Dir <- SearchDirs], case find_existing_file(Candidates) of {ok, Found} -> {ok, filename:absname(Found)}; error -> resolve_include_lib(Path) end. %%==================================================================== %% Internal functions - Scanning %%==================================================================== %% @doc Core scanning implementation used by all scan_file variants %% This function contains the actual logic for scanning files and resolving dependencies -spec do_scan_file(file:filename(), file:filename(), [file:filename()], map()) -> [file:filename()]. do_scan_file(SourceFile, AppDir, IncludeDirs, Opts) -> UseCache = maps:get(cache, Opts, true), %% Check cache first case UseCache andalso r3lfe_dep_cache:get(SourceFile) of {ok, CachedDeps, _Time} -> ?DEBUG("Using cached dependencies for ~s", [SourceFile]), CachedDeps; _ -> %% Cache miss or disabled, scan the file case file:read_file(SourceFile) of {ok, Binary} -> Content = binary_to_list(Binary), IncludeForms = scan_content(Content), %% Resolve each include form to an absolute path ResolvedPaths = lists:filtermap( fun(Form) -> case resolve_include(Form, AppDir, IncludeDirs) of {ok, Path} -> {true, Path}; {error, Reason} -> ?WARN("Could not resolve include in ~s: ~p", [SourceFile, Reason]), false end end, IncludeForms ), Result = lists:usort(ResolvedPaths), %% Cache the result UseCache andalso r3lfe_dep_cache:put(SourceFile, Result), Result; {error, Reason} -> ?ERROR("Failed to read ~s: ~p", [SourceFile, Reason]), [] end end. %%==================================================================== %% Internal functions - Parsing %%==================================================================== %% @doc Parse LFE content for include forms %% Matches: (include-file "path") and (include-lib "app/path") -spec parse_include_forms(string()) -> [string()]. parse_include_forms(Content) -> %% Regular expression to match include forms %% Captures the entire form: (include-file "...") or (include-lib "...") Pattern = "\\(include-(?:file|lib)\\s+\"[^\"]+\"\\)", case re:run(Content, Pattern, [global, {capture, all, list}]) of {match, Matches} -> %% Matches is [[FullMatch], [FullMatch], ...] [Match || [Match] <- Matches]; nomatch -> [] end. %% @doc Extract include type and path from a matched form -spec extract_include_path(string()) -> {ok, include_type(), string()} | error. extract_include_path(Form) -> %% Form looks like: (include-file "path/to/file.lfe") %% or: (include-lib "app/include/file.lfe") %% Extract the type and path TypePattern = "\\(include-(file|lib)\\s+\"([^\"]+)\"\\)", case re:run(Form, TypePattern, [{capture, all_but_first, list}]) of {match, ["file", Path]} -> {ok, include_file, Path}; {match, ["lib", Path]} -> {ok, include_lib, Path}; _ -> error end. %% @doc Classify an include path (for diagnostics/logging) -spec classify_include(string()) -> include_type(). classify_include(Path) -> case string:find(Path, "/") of nomatch -> include_file; _ -> %% If it looks like "app/..." it's probably include-lib case re:run(Path, "^[a-z_]+/", [{capture, none}]) of match -> include_lib; nomatch -> include_file end end. %%==================================================================== %% Internal functions - Resolution %%==================================================================== %% @doc Resolve an include-file path %% Searches in: %% 1. Include directories (in order) %% 2. Relative to app directory %% 3. As absolute path (if given) -spec resolve_include_file(string(), file:filename(), [file:filename()]) -> {ok, file:filename()} | {error, not_found}. resolve_include_file(Path, AppDir, IncludeDirs) -> %% Build list of candidate paths to check %% Note: Use lists:append, not lists:flatten, to avoid flattening strings IncludeCandidates = [filename:join(IncDir, Path) || IncDir <- IncludeDirs], AppCandidates = [filename:join(AppDir, Path)], AbsoluteCandidates = [Path], AllCandidates = IncludeCandidates ++ AppCandidates ++ AbsoluteCandidates, case find_existing_file(AllCandidates) of {ok, Found} -> {ok, filename:absname(Found)}; error -> ?DEBUG("Could not find include-file: ~s", [Path]), ?DEBUG("Searched in: ~p", [AllCandidates]), {error, not_found} end. %% @doc Resolve an include-lib path %% Format: "app/include/file.lfe" or "app/path/to/file.lfe" %% Resolves using code:lib_dir/1 -spec resolve_include_lib(string()) -> {ok, file:filename()} | {error, term()}. resolve_include_lib(Path) -> case string:split(Path, "/", leading) of [AppName, RestPath] -> %% Convert app name string to atom %% Use list_to_atom (not list_to_existing_atom) because dependency %% applications may not be loaded yet during compilation AppAtom = list_to_atom(AppName), case code:lib_dir(AppAtom) of {error, bad_name} -> ?DEBUG("Application not found: ~s", [AppName]), {error, {app_not_found, AppName}}; AppDir -> FullPath = filename:join(AppDir, RestPath), case filelib:is_file(FullPath) of true -> {ok, filename:absname(FullPath)}; false -> ?DEBUG("File not found: ~s", [FullPath]), {error, not_found} end end; _ -> ?ERROR("Invalid include-lib path format: ~s", [Path]), {error, invalid_format} end. %% @doc Find the first existing file in a list of candidates -spec find_existing_file([file:filename()]) -> {ok, file:filename()} | error. find_existing_file([]) -> error; find_existing_file([Path | Rest]) -> case filelib:is_file(Path) of true -> {ok, Path}; false -> find_existing_file(Rest) end.