Current section

Files

Jump to
hologram lib hologram framework.ex
Raw

lib/hologram/framework.ex

defmodule Hologram.Framework do
@moduledoc false
alias Hologram.Commons.PLT
alias Hologram.Compiler
alias Hologram.Compiler.CallGraph
alias Hologram.Compiler.Digraph
alias Hologram.Reflection
@elixir_stdlib_module_groups [
{"Core",
[
Kernel,
Kernel.SpecialForms
]},
{"Data Types",
[
Atom,
Base,
Bitwise,
Date,
DateTime,
Duration,
Exception,
Float,
Function,
Integer,
JSON,
Module,
NaiveDateTime,
Record,
Regex,
String,
Time,
Tuple,
URI,
Version,
Version.Requirement
]},
{"Collections & Enumerables",
[
Access,
Date.Range,
Enum,
Keyword,
List,
Map,
MapSet,
Range,
Stream
]},
{"IO & System",
[
File,
File.Stat,
File.Stream,
IO,
IO.ANSI,
IO.Stream,
OptionParser,
Path,
Port,
StringIO,
System
]},
{"Calendar",
[
Calendar,
Calendar.ISO,
Calendar.TimeZoneDatabase,
Calendar.UTCOnlyTimeZoneDatabase
]},
{"Processes & Applications",
[
Agent,
Application,
Config,
Config.Provider,
Config.Reader,
DynamicSupervisor,
GenServer,
Node,
PartitionSupervisor,
Process,
Registry,
Supervisor,
Task,
Task.Supervisor
]},
{"Protocols",
[
Collectable,
Enumerable,
Inspect,
Inspect.Algebra,
Inspect.Opts,
List.Chars,
Protocol,
String.Chars
]},
{"Code & Macros",
[
Code,
Code.Fragment,
Kernel.ParallelCompiler,
Macro,
Macro.Env
]},
{"Exceptions",
[
ArgumentError,
ArithmeticError,
BadArityError,
BadBooleanError,
BadFunctionError,
BadMapError,
CaseClauseError,
Code.LoadError,
CompileError,
CondClauseError,
Enum.EmptyError,
Enum.OutOfBoundsError,
ErlangError,
File.CopyError,
File.Error,
File.LinkError,
File.RenameError,
FunctionClauseError,
IO.StreamError,
Inspect.Error,
JSON.DecodeError,
Kernel.TypespecError,
KeyError,
MatchError,
MismatchedDelimiterError,
MissingApplicationsError,
OptionParser.ParseError,
Protocol.UndefinedError,
Regex.CompileError,
RuntimeError,
SyntaxError,
System.EnvError,
SystemLimitError,
TokenMissingError,
TryClauseError,
URI.Error,
UndefinedFunctionError,
UnicodeConversionError,
Version.InvalidRequirementError,
Version.InvalidVersionError,
WithClauseError
]}
]
@doc """
Builds a call graph for Elixir standard library with macro dependencies.
## Parameters
- `macro_deps` - Map of macro dependencies where keys are MFA tuples and values are lists of MFA tuples they depend on.
Edges from the key to each value in the list will be injected into the call graph.
## Returns
A directed graph containing call relationships between Elixir stdlib functions/macros and their dependencies.
Only includes functions from Elixir stdlib modules (`:elixir` OTP app) and Erlang modules.
Non-stdlib Elixir modules (e.g., from `:ecto`, `:phoenix`) are filtered out.
"""
@spec build_stdlib_call_graph(%{mfa => [mfa]}) :: Digraph.t()
def build_stdlib_call_graph(macro_deps) do
macro_edges =
for {from_mfa, to_mfas} <- macro_deps, to_mfa <- to_mfas do
{from_mfa, to_mfa}
end
graph =
build_stdlib_ir_plt()
|> Compiler.build_call_graph()
|> CallGraph.remove_manually_ported_mfas()
|> CallGraph.add_edges(macro_edges)
|> CallGraph.get_graph()
# Remove non-stdlib Elixir modules (keep only :elixir app and Erlang modules)
non_stdlib_elixir_mfas =
graph.vertices
|> Map.keys()
|> Enum.filter(fn
{module, _fun, _arity} ->
Reflection.elixir_module?(module) &&
match?({:ok, app} when app != :elixir, :application.get_application(module))
_non_mfa ->
false
end)
Digraph.remove_vertices(graph, non_stdlib_elixir_mfas)
end
@doc """
Aggregates information about Elixir standard library functions and macros.
## Parameters
- `erlang_js_dir` - Path to the directory containing manually ported Erlang functions
- `stdlib_deps` - Precomputed Elixir stdlib Erlang dependencies from `elixir_stdlib_erlang_deps/1`
- `opts` - Keyword list with required keys:
- `:deferred_elixir_funs` - List of Elixir MFA tuples that are deferred
- `:in_progress_erlang_funs` - List of Erlang MFA tuples currently being ported
- `:deferred_erlang_funs` - List of Erlang MFA tuples that are deferred
- `:overrides` - Map of Elixir MFA tuples to `{status, progress}` tuples where
status is one of `:done`, `:in_progress`, `:todo`, or `:deferred` and progress is
an integer between 0 and 100. Both values override the calculated status and progress.
"""
@spec elixir_funs_info(Path.t(), %{module => %{{fun, arity} => [mfa]}}, keyword()) :: %{
mfa => %{
status: :done | :in_progress | :todo | :deferred,
progress: non_neg_integer(),
method: :auto | :manual,
dependencies: [mfa],
dependencies_count: non_neg_integer()
}
}
def elixir_funs_info(erlang_js_dir, stdlib_deps, opts) do
deferred_elixir_funs = Keyword.fetch!(opts, :deferred_elixir_funs)
in_progress_erlang_funs = Keyword.fetch!(opts, :in_progress_erlang_funs)
deferred_erlang_funs = Keyword.fetch!(opts, :deferred_erlang_funs)
overrides = Keyword.fetch!(opts, :overrides)
deferred_elixir_funs_set = MapSet.new(deferred_elixir_funs)
erlang_funs_info =
erlang_funs_info(erlang_js_dir, stdlib_deps,
in_progress_erlang_funs: in_progress_erlang_funs,
deferred_erlang_funs: deferred_erlang_funs
)
for {module, funs} <- stdlib_deps, {{fun, arity}, erlang_deps} <- funs, into: %{} do
elixir_mfa = {module, fun, arity}
{status, progress} =
resolve_elixir_fun_status_and_progess(
elixir_mfa,
erlang_deps,
erlang_funs_info,
deferred_elixir_funs_set,
overrides
)
{elixir_mfa,
%{
status: status,
progress: progress,
method: elixir_fun_method(elixir_mfa),
dependencies: erlang_deps,
dependencies_count: length(erlang_deps)
}}
end
end
@doc """
Aggregates information about Elixir standard library modules.
Includes both functions and macros for each module.
## Parameters
- `erlang_js_dir` - Path to the directory containing manually ported Erlang functions
- `stdlib_deps` - Precomputed Elixir stdlib Erlang dependencies from `elixir_stdlib_erlang_deps/1`
- `opts` - Keyword list with required keys:
- `:deferred_elixir_modules` - List of Elixir modules that are deferred
- `:deferred_elixir_funs` - List of Elixir MFA tuples that are deferred
- `:in_progress_erlang_funs` - List of Erlang MFA tuples currently being worked on
- `:deferred_erlang_funs` - List of Erlang MFA tuples that are deferred
- `:overrides` - Map of Elixir MFA tuples to `{status, progress}` tuples where
status is one of `:done`, `:in_progress`, `:todo`, or `:deferred` and progress is
an integer between 0 and 100. Both values override the calculated status and progress.
"""
@spec elixir_modules_info(Path.t(), %{module => %{{fun, arity} => [mfa]}}, keyword()) :: %{
module => %{
group: String.t(),
status: :done | :in_progress | :todo | :deferred,
progress: non_neg_integer(),
functions: [{atom, arity}],
all_fun_count: non_neg_integer(),
done_fun_count: non_neg_integer(),
in_progress_fun_count: non_neg_integer(),
todo_fun_count: non_neg_integer(),
deferred_fun_count: non_neg_integer()
}
}
def elixir_modules_info(erlang_js_dir, stdlib_deps, opts) do
deferred_elixir_modules = Keyword.fetch!(opts, :deferred_elixir_modules)
deferred_elixir_funs = Keyword.fetch!(opts, :deferred_elixir_funs)
in_progress_erlang_funs = Keyword.fetch!(opts, :in_progress_erlang_funs)
deferred_erlang_funs = Keyword.fetch!(opts, :deferred_erlang_funs)
overrides = Keyword.fetch!(opts, :overrides)
deferred_elixir_modules_set = MapSet.new(deferred_elixir_modules)
elixir_funs_info =
elixir_funs_info(erlang_js_dir, stdlib_deps,
deferred_elixir_funs: deferred_elixir_funs,
in_progress_erlang_funs: in_progress_erlang_funs,
deferred_erlang_funs: deferred_erlang_funs,
overrides: overrides
)
elixir_stdlib_module_groups = elixir_stdlib_module_groups()
module_to_group = map_module_to_group(elixir_stdlib_module_groups)
for {_group, modules} <- elixir_stdlib_module_groups, module <- modules, into: %{} do
group = module_to_group[module]
funs_and_macros =
:functions
|> module.__info__()
|> Enum.concat(module.__info__(:macros))
|> filter_out_internal()
|> Enum.sort()
module_funs_info =
for {fun, arity} <- funs_and_macros do
elixir_funs_info[{module, fun, arity}]
end
status =
calculate_elixir_module_status(module, module_funs_info, deferred_elixir_modules_set)
progress = calculate_elixir_module_progress(module_funs_info)
fun_counts = aggregate_counts_by_status(module_funs_info, "fun", & &1.status)
{module,
%{
group: group,
status: status,
progress: progress,
functions: funs_and_macros,
all_fun_count: length(funs_and_macros),
done_fun_count: fun_counts.done_fun_count,
in_progress_fun_count: fun_counts.in_progress_fun_count,
todo_fun_count: fun_counts.todo_fun_count,
deferred_fun_count: fun_counts.deferred_fun_count
}}
end
end
@doc """
Returns overview statistics of Elixir functions porting status.
## Parameters
- `erlang_js_dir` - Path to the directory containing manually ported Erlang functions
- `stdlib_deps` - Precomputed Elixir stdlib Erlang dependencies from `elixir_stdlib_erlang_deps/1`
- `opts` - Keyword list with required keys:
- `:deferred_elixir_modules` - List of Elixir modules that are deferred
- `:deferred_elixir_funs` - List of Elixir MFA tuples that are deferred
- `:in_progress_erlang_funs` - List of Erlang MFA tuples currently being ported
- `:deferred_erlang_funs` - List of Erlang MFA tuples that are deferred
- `:overrides` - Map of Elixir MFA tuples to `{status, progress}` tuples where
status is one of `:done`, `:in_progress`, `:todo`, or `:deferred` and progress is
an integer between 0 and 100. Both values override the calculated status and progress.
"""
@spec elixir_overview_stats(Path.t(), %{module => %{{fun, arity} => [mfa]}}, keyword()) :: %{
done_fun_count: non_neg_integer(),
in_progress_fun_count: non_neg_integer(),
todo_fun_count: non_neg_integer(),
deferred_fun_count: non_neg_integer(),
done_module_count: non_neg_integer(),
in_progress_module_count: non_neg_integer(),
todo_module_count: non_neg_integer(),
deferred_module_count: non_neg_integer(),
progress: non_neg_integer()
}
def elixir_overview_stats(erlang_js_dir, stdlib_deps, opts) do
elixir_funs_info = elixir_funs_info(erlang_js_dir, stdlib_deps, opts)
fun_status_fetcher = fn {_elixir_mfa, %{status: status}} -> status end
fun_stats = aggregate_counts_by_status(elixir_funs_info, "fun", fun_status_fetcher)
elixir_modules_info = elixir_modules_info(erlang_js_dir, stdlib_deps, opts)
module_status_fetcher = fn {_module, %{status: status}} -> status end
module_stats =
aggregate_counts_by_status(elixir_modules_info, "module", module_status_fetcher)
progress = calculate_elixir_overall_progress(elixir_funs_info)
fun_stats
|> Map.merge(module_stats)
|> Map.put(:progress, progress)
end
@doc """
Returns Erlang dependencies for Elixir standard library modules.
Analyzes the call graph of selected Elixir standard library modules and identifies
which Erlang functions they depend on. Includes both functions and macros.
Manually ported functions are excluded.
## Parameters
- `macro_deps` - Map of macro dependencies where keys are MFA tuples and values are lists of MFA tuples they depend on.
Edges from the key to each value in the list will be injected into the call graph.
## Return Structure
Returns a two-level nested map:
- **Level 1**: Module (atom) -> functions and macros in that module
- **Level 2**: Function/macro key `{name, arity}` -> list of reachable Erlang MFAs
## Example
iex> deps = elixir_stdlib_erlang_deps(%{{Kernel, :!, 1} => [{Kernel, :in, 2}]})
iex> deps[Kernel][{:hd, 1}]
[{:erlang, :hd, 1}]
"""
@spec elixir_stdlib_erlang_deps(%{mfa => [mfa]}) :: %{module => %{{fun, arity} => [mfa]}}
def elixir_stdlib_erlang_deps(macro_deps) do
graph = build_stdlib_call_graph(macro_deps)
# Now query the graph for each documented stdlib function
elixir_stdlib_module_groups()
|> Enum.flat_map(fn {_group, modules} -> modules end)
|> Enum.reduce(%{}, fn module, modules_acc ->
funs_and_macros =
:functions
|> module.__info__()
|> Enum.concat(module.__info__(:macros))
|> filter_out_internal()
module_map =
for {fun, arity} <- funs_and_macros, into: %{} do
reachable_erlang_mfas = reachable_erlang_mfas(graph, {module, fun, arity})
{{fun, arity}, reachable_erlang_mfas}
end
Map.put(modules_acc, module, module_map)
end)
end
@doc """
Returns grouped Elixir standard library modules.
The return value is a list of pairs `{group_name, modules}` where
`group_name` is a string and `modules` is a list of Elixir module atoms.
Modules that are not available in the current Elixir version are automatically filtered out.
"""
@spec elixir_stdlib_module_groups() :: [{String.t(), [module]}]
def elixir_stdlib_module_groups do
Enum.map(@elixir_stdlib_module_groups, fn {group_name, modules} ->
available_modules = Enum.filter(modules, &Code.ensure_loaded?/1)
{group_name, available_modules}
end)
end
@doc """
Aggregates information about Erlang functions used by Elixir stdlib.
## Parameters
- `erlang_js_dir` - Path to the directory containing manually ported Erlang functions
- `stdlib_deps` - Precomputed Elixir stdlib Erlang dependencies from `elixir_stdlib_erlang_deps/1`
- `opts` - Keyword list with required keys:
- `:in_progress_erlang_funs` - List of MFA tuples currently being ported
- `:deferred_erlang_funs` - List of MFA tuples that are deferred
Note: `:in_progress` takes precedence over `:deferred` when determining status.
"""
@spec erlang_funs_info(Path.t(), %{module => %{{fun, arity} => [mfa]}}, keyword()) :: %{
mfa => %{
status: :done | :in_progress | :todo | :deferred,
dependents: [mfa],
dependents_count: non_neg_integer()
}
}
def erlang_funs_info(erlang_js_dir, stdlib_deps, opts) do
done_erlang_funs_set =
erlang_js_dir
|> list_ported_erlang_funs()
|> MapSet.new()
in_progress_erlang_funs_set =
opts
|> Keyword.fetch!(:in_progress_erlang_funs)
|> MapSet.new()
deferred_erlang_funs_set =
opts
|> Keyword.fetch!(:deferred_erlang_funs)
|> MapSet.new()
stdlib_deps
|> dependents_by_erlang_mfa()
|> Enum.map(fn {erlang_mfa, dependents} ->
unique_dependents = Enum.uniq(dependents)
status =
calculate_erlang_fun_status(
erlang_mfa,
done_erlang_funs_set,
in_progress_erlang_funs_set,
deferred_erlang_funs_set
)
{erlang_mfa,
%{
status: status,
dependents: unique_dependents,
dependents_count: length(unique_dependents)
}}
end)
|> Enum.into(%{})
end
@doc """
Returns overview statistics of Erlang functions porting status.
## Parameters
- `erlang_js_dir` - Path to the directory containing manually ported Erlang functions
- `stdlib_deps` - Precomputed Elixir stdlib Erlang dependencies from `elixir_stdlib_erlang_deps/1`
- `opts` - Keyword list with required keys:
- `:in_progress_erlang_funs` - List of Erlang MFA tuples currently being ported
- `:deferred_erlang_funs` - List of Erlang MFA tuples that are deferred
"""
@spec erlang_overview_stats(Path.t(), %{module => %{{fun, arity} => [mfa]}}, keyword()) :: %{
done_count: non_neg_integer(),
in_progress_count: non_neg_integer(),
todo_count: non_neg_integer(),
deferred_count: non_neg_integer(),
progress: non_neg_integer()
}
def erlang_overview_stats(erlang_js_dir, stdlib_deps, opts) do
erlang_funs_info = erlang_funs_info(erlang_js_dir, stdlib_deps, opts)
status_fetcher = fn {_mfa, %{status: status}} -> status end
stats = aggregate_counts_by_status(erlang_funs_info, "fun", status_fetcher)
# Deferred functions are excluded from the calculation
total_relevant_fun_count =
stats.done_fun_count + stats.todo_fun_count + stats.in_progress_fun_count
progress =
if total_relevant_fun_count > 0 do
round(stats.done_fun_count * 100 / total_relevant_fun_count)
else
0
end
Map.put(stats, :progress, progress)
end
@doc """
Lists all manually ported Erlang functions.
## Parameters
- `erlang_js_dir` - Path to the directory containing manually ported Erlang functions JavaScript files in the Hologram library
## Returns
A list of MFAs (module, function, arity tuples).
## Example
iex> list_ported_erlang_funs("assets/js/erlang")
[{:erlang, :*, 2}, {:lists, :flatten, 1}, {:maps, :get, 2}, ...]
"""
@spec list_ported_erlang_funs(Path.t()) :: [mfa]
def list_ported_erlang_funs(erlang_js_dir) do
erlang_js_dir
|> File.ls!()
|> Enum.flat_map(fn file ->
module = extract_module_name(file)
path = Path.join(erlang_js_dir, file)
path
|> File.read!()
|> extract_function_signatures()
|> Enum.map(fn {fun, arity} -> {module, fun, arity} end)
end)
end
# sobelow_skip ["DOS.BinToAtom"]
defp aggregate_counts_by_status(items_info, item_type, status_fetcher) do
Enum.reduce(
items_info,
# credo:disable-for-lines:6 Credo.Check.Warning.UnsafeToAtom
%{
"done_#{item_type}_count": 0,
"in_progress_#{item_type}_count": 0,
"todo_#{item_type}_count": 0,
"deferred_#{item_type}_count": 0
},
fn item_info, acc ->
status = status_fetcher.(item_info)
count_key = String.to_existing_atom("#{status}_#{item_type}_count")
Map.update!(acc, count_key, &(&1 + 1))
end
)
end
defp build_stdlib_ir_plt do
# Build IR PLT for all modules, then filter to only :elixir OTP app modules
# This includes both documented stdlib modules AND internal implementation modules
# that are part of the Elixir stdlib (e.g., String.Break, Enum.EmptyError)
ir_plt = Compiler.build_ir_plt()
stdlib_ir_items =
ir_plt
|> PLT.get_all()
|> Enum.filter(fn {module, _ir} ->
case :application.get_application(module) do
{:ok, :elixir} -> true
_other -> false
end
end)
# Build call graph only from :elixir app modules
# This ensures we only include edges within stdlib (including internal modules)
# and to Erlang modules, without edges from non-stdlib modules
PLT.start(items: stdlib_ir_items)
end
defp calculate_elixir_fun_progress([], _erlang_funs_info), do: 100
defp calculate_elixir_fun_progress(erlang_deps, erlang_funs_info) do
done_dep_count = Enum.count(erlang_deps, &(erlang_funs_info[&1].status == :done))
round(done_dep_count * 100 / length(erlang_deps))
end
defp calculate_elixir_fun_status(
elixir_mfa,
erlang_deps,
erlang_funs_info,
deferred_elixir_funs_set
) do
cond do
Enum.all?(erlang_deps, &(erlang_funs_info[&1].status == :done)) ->
:done
MapSet.member?(deferred_elixir_funs_set, elixir_mfa) ->
:deferred
Enum.any?(erlang_deps, &(erlang_funs_info[&1].status in [:done, :in_progress])) ->
:in_progress
true ->
:todo
end
end
defp calculate_elixir_module_progress(elixir_funs_info)
defp calculate_elixir_module_progress([]), do: 100
defp calculate_elixir_module_progress(elixir_funs_info) do
progress_sum = Enum.reduce(elixir_funs_info, 0, &(&2 + &1.progress))
round(progress_sum / length(elixir_funs_info))
end
defp calculate_elixir_module_status(module, elixir_funs_info, deferred_elixir_modules_set) do
cond do
Enum.all?(elixir_funs_info, &(&1.status == :done)) ->
:done
MapSet.member?(deferred_elixir_modules_set, module) ->
:deferred
Enum.any?(elixir_funs_info, &(&1.status in [:done, :in_progress])) ->
:in_progress
true ->
:todo
end
end
# Calculate progress as average of non-deferred function progresses
defp calculate_elixir_overall_progress(elixir_funs_info) do
non_deferred_funs =
Enum.filter(elixir_funs_info, fn {_elixir_mfa, info} -> info.status != :deferred end)
non_deferred_fun_count = length(non_deferred_funs)
if non_deferred_fun_count > 0 do
total_progress =
Enum.reduce(non_deferred_funs, 0, fn {_elixir_mfa, info}, acc ->
acc + info.progress
end)
round(total_progress / non_deferred_fun_count)
else
0
end
end
defp calculate_erlang_fun_status(
erlang_mfa,
done_erlang_funs_set,
in_progress_erlang_funs_set,
deferred_erlang_funs_set
) do
cond do
MapSet.member?(done_erlang_funs_set, erlang_mfa) -> :done
MapSet.member?(in_progress_erlang_funs_set, erlang_mfa) -> :in_progress
MapSet.member?(deferred_erlang_funs_set, erlang_mfa) -> :deferred
true -> :todo
end
end
# Build a map of erlang_mfa => list of elixir mfas that depend on it
defp dependents_by_erlang_mfa(modules_map) do
for {module, funs} <- modules_map,
{{fun, arity}, erlang_mfas} <- funs,
erlang_mfa <- erlang_mfas,
reduce: %{} do
acc ->
Map.update(acc, erlang_mfa, [{module, fun, arity}], fn existing ->
[{module, fun, arity} | existing]
end)
end
end
defp elixir_fun_method(elixir_mfa) do
if elixir_mfa in CallGraph.manually_ported_elixir_mfas() do
:manual
else
:auto
end
end
defp extract_function_signatures(content) do
~r/\/\/ Start (.+?)\/(\d+)/
|> Regex.scan(content)
|> Enum.map(fn [_full, fun, arity] ->
{String.to_existing_atom(fun), String.to_integer(arity)}
end)
end
defp extract_module_name(filename) do
filename
|> Path.basename(".mjs")
|> String.to_existing_atom()
end
defp filter_out_internal(funs_and_macros) do
Enum.reject(funs_and_macros, fn {fun, _arity} ->
fun
|> to_string()
|> String.starts_with?("__")
end)
end
# Build a map of module => group
defp map_module_to_group(module_groups) do
module_groups
|> Enum.flat_map(fn {group, modules} ->
Enum.map(modules, fn module -> {module, group} end)
end)
|> Enum.into(%{})
end
defp reachable_erlang_mfas(graph, source_mfa) do
graph
|> CallGraph.reachable_mfas([source_mfa])
|> Enum.filter(fn {module, _fun, _arity} -> Reflection.erlang_module?(module) end)
end
defp resolve_elixir_fun_status_and_progess(
elixir_mfa,
erlang_deps,
erlang_funs_info,
deferred_elixir_funs_set,
overrides
) do
calculated_status =
calculate_elixir_fun_status(
elixir_mfa,
erlang_deps,
erlang_funs_info,
deferred_elixir_funs_set
)
calculated_progress = calculate_elixir_fun_progress(erlang_deps, erlang_funs_info)
# Apply status and progress overrides if present
case Map.get(overrides, elixir_mfa) do
{override_status, override_progress} -> {override_status, override_progress}
nil -> {calculated_status, calculated_progress}
end
end
end