Current section

26 Versions

Jump to

Compare versions

10 files changed
+211 additions
-676 deletions
  @@ -467,8 +467,8 @@ const Erlang = {
467 467 );
468 468 },
469 469 // End binary_part/3
470 -
471 470 // Deps: []
471 +
472 472 // Start binary_to_atom/1
473 473 "binary_to_atom/1": (binary) => {
474 474 return Erlang["binary_to_atom/2"](binary, Type.atom("utf8"));
  @@ -8,7 +8,7 @@
8 8 {<<"Sponsor">>,<<"https://github.com/sponsors/bartblast">>},
9 9 {<<"Website">>,<<"https://hologram.page">>}]}.
10 10 {<<"name">>,<<"hologram">>}.
11 - {<<"version">>,<<"0.8.2">>}.
11 + {<<"version">>,<<"0.8.3">>}.
12 12 {<<"description">>,
13 13 <<"Full stack isomorphic Elixir web framework that can be used on top of Phoenix.">>}.
14 14 {<<"elixir">>,<<"~> 1.0">>}.
  @@ -75,8 +75,6 @@
75 75 <<"lib/mix/tasks/holo/compiler/ex_runtime_mfas.ex">>,
76 76 <<"lib/mix/tasks/holo/compiler/runtime_to_mfa_paths.ex">>,
77 77 <<"lib/mix/tasks/holo/compiler/page_ex_fun_sizes.ex">>,
78 - <<"lib/mix/tasks/holo/compiler/runtime_mfa_cascades.ex">>,
79 - <<"lib/mix/tasks/holo/compiler/page_mfa_cascades.ex">>,
80 78 <<"lib/mix/tasks/compile">>,<<"lib/mix/tasks/compile/hologram.ex">>,
81 79 <<"lib/hologram">>,<<"lib/hologram/reflection.ex">>,<<"lib/hologram/ui">>,
82 80 <<"lib/hologram/ui/runtime.ex">>,<<"lib/hologram/ui/link.ex">>,
  @@ -91,12 +91,10 @@ defmodule Hologram.Compiler do
91 91
92 92 ir_plt
93 93 |> PLT.get_all()
94 - |> TaskUtils.async_many(fn {_module, ir} ->
95 - CallGraph.build(call_graph, ir, %CallGraph.Context{})
96 - end)
94 + |> TaskUtils.async_many(fn {_module, ir} -> CallGraph.build(call_graph, ir) end)
97 95 |> Task.await_many(:infinity)
98 96
99 - call_graph
97 + CallGraph.add_non_discoverable_edges(call_graph)
100 98 end
101 99
102 100 @doc """
  @@ -5,41 +5,10 @@ defmodule Hologram.Compiler.CallGraph do
5 5 alias Hologram.Commons.SerializationUtils
6 6 alias Hologram.Commons.TaskUtils
7 7 alias Hologram.Compiler.CallGraph
8 - alias Hologram.Compiler.CallGraph.Context
9 8 alias Hologram.Compiler.Digraph
10 9 alias Hologram.Compiler.IR
11 10 alias Hologram.Reflection
12 11
13 - defmodule Context.Modifiers do
14 - @moduledoc false
15 -
16 - @type t :: %__MODULE__{
17 - suppress_edges_to_module_vertices?: bool
18 - }
19 -
20 - defstruct suppress_edges_to_module_vertices?: false
21 - end
22 -
23 - defmodule Context do
24 - @moduledoc false
25 -
26 - alias Hologram.Compiler.CallGraph.Context.Modifiers
27 -
28 - @type t :: %__MODULE__{
29 - from_vertex: module | mfa | nil,
30 - guard?: bool,
31 - modifiers: Modifiers.t(),
32 - pattern?: bool,
33 - protocol_impl: module | nil
34 - }
35 -
36 - defstruct from_vertex: nil,
37 - guard?: false,
38 - modifiers: %Modifiers{},
39 - pattern?: false,
40 - protocol_impl: nil
41 - end
42 -
43 12 defstruct pid: nil
44 13
45 14 @type t :: %CallGraph{pid: pid}
  @@ -47,8 +16,52 @@ defmodule Hologram.Compiler.CallGraph do
47 16 @type edge :: {vertex, vertex}
48 17 @type vertex :: module | mfa
49 18
50 - # Page functions needed by other pages for link building (e.g. in Hologram.UI.Link component).
51 - @cross_page_funs [{:__params__, 0}, {:__route__, 0}]
19 + # These edges can't be discovered from static IR analysis.
20 + @dynamic_dispatch_edges [
21 + {{Date, :day_of_era, 1}, {Calendar.ISO, :day_of_era, 3}},
22 + {{Date, :day_of_week, 2}, {Calendar.ISO, :day_of_week, 4}},
23 + {{Date, :day_of_year, 1}, {Calendar.ISO, :day_of_year, 3}},
24 + {{Date, :days_in_month, 1}, {Calendar.ISO, :days_in_month, 2}},
25 + {{Date, :leap_year?, 1}, {Calendar.ISO, :leap_year?, 1}},
26 + {{Date, :months_in_year, 1}, {Calendar.ISO, :months_in_year, 1}},
27 + {{Date, :new, 4}, {Calendar.ISO, :valid_date?, 3}},
28 + {{Date, :quarter_of_year, 1}, {Calendar.ISO, :quarter_of_year, 3}},
29 + {{Date, :shift, 2}, {Calendar.ISO, :shift_date, 4}},
30 + {{Date, :to_string, 1}, {Calendar.ISO, :date_to_string, 3}},
31 + {{Date, :year_of_era, 1}, {Calendar.ISO, :year_of_era, 3}},
32 + {{DateTime, :from_gregorian_seconds, 3}, {Calendar.ISO, :naive_datetime_from_iso_days, 1}},
33 + {{DateTime, :from_iso_days, 4}, {Calendar.ISO, :naive_datetime_from_iso_days, 1}},
34 + {{DateTime, :shift, 3}, {Calendar.ISO, :naive_datetime_to_iso_days, 7}},
35 + {{DateTime, :shift, 3}, {Calendar.ISO, :shift_naive_datetime, 8}},
36 + {{DateTime, :shift_by_offset, 2}, {Calendar.ISO, :naive_datetime_from_iso_days, 1}},
37 + {{DateTime, :shift_zone_for_iso_days_utc, 5},
38 + {Calendar.ISO, :naive_datetime_from_iso_days, 1}},
39 + {{DateTime, :to_iso_days, 1}, {Calendar.ISO, :naive_datetime_to_iso_days, 7}},
40 + {{DateTime, :to_string, 1}, {Calendar.ISO, :datetime_to_string, 11}},
41 + {{Inspect.Date, :inspect, 2}, {Calendar.ISO, :date_to_string, 3}},
42 + {{Inspect.DateTime, :inspect, 2}, {Calendar.ISO, :datetime_to_string, 11}},
43 + {{Inspect.NaiveDateTime, :inspect, 2}, {Calendar.ISO, :naive_datetime_to_string, 7}},
44 + {{Inspect.Time, :inspect, 2}, {Calendar.ISO, :time_to_string, 4}},
45 + {{NaiveDateTime, :beginning_of_day, 1}, {Calendar.ISO, :iso_days_to_beginning_of_day, 1}},
46 + {{NaiveDateTime, :end_of_day, 1}, {Calendar.ISO, :iso_days_to_end_of_day, 1}},
47 + {{NaiveDateTime, :from_iso_days, 3}, {Calendar.ISO, :naive_datetime_from_iso_days, 1}},
48 + {{NaiveDateTime, :new, 8}, {Calendar.ISO, :valid_date?, 3}},
49 + {{NaiveDateTime, :new, 8}, {Calendar.ISO, :valid_time?, 4}},
50 + {{NaiveDateTime, :shift, 2}, {Calendar.ISO, :shift_naive_datetime, 8}},
51 + {{NaiveDateTime, :to_gregorian_seconds, 1}, {Calendar.ISO, :naive_datetime_to_iso_days, 7}},
52 + {{NaiveDateTime, :to_iso_days, 1}, {Calendar.ISO, :naive_datetime_to_iso_days, 7}},
53 + {{NaiveDateTime, :to_string, 1}, {Calendar.ISO, :naive_datetime_to_string, 7}},
54 + {{String.Chars.Date, :to_string, 1}, {Calendar.ISO, :date_to_string, 3}},
55 + {{String.Chars.DateTime, :to_string, 1}, {Calendar.ISO, :datetime_to_string, 11}},
56 + {{String.Chars.NaiveDateTime, :to_string, 1}, {Calendar.ISO, :naive_datetime_to_string, 7}},
57 + {{String.Chars.Time, :to_string, 1}, {Calendar.ISO, :time_to_string, 4}},
58 + {{Time, :convert, 2}, {Calendar.ISO, :time_from_day_fraction, 1}},
59 + {{Time, :from_seconds_after_midnight, 3}, {Calendar.ISO, :time_from_day_fraction, 1}},
60 + {{Time, :new, 5}, {Calendar.ISO, :valid_time?, 4}},
61 + {{Time, :shift, 2}, {Calendar.ISO, :shift_time, 5}},
62 + {{Time, :to_day_fraction, 1}, {Calendar.ISO, :time_to_day_fraction, 4}},
63 + {{Time, :to_string, 1}, {Calendar.ISO, :time_to_string, 4}}
64 + ]
52 65
53 66 # TODO: Determine automatically based on deps annotations next to function implementations
54 67 @erlang_mfa_edges [
  @@ -332,6 +345,22 @@ defmodule Hologram.Compiler.CallGraph do
332 345 call_graph
333 346 end
334 347
348 + @doc """
349 + Adds call graph edges that can't be discovered from static IR analysis:
350 + Erlang functions depending on other Erlang functions, and dynamic dispatch
351 + patterns in Elixir stdlib (e.g. behaviour callbacks called via variable with known default).
352 + """
353 + @spec add_non_discoverable_edges(t) :: t
354 + def add_non_discoverable_edges(%{pid: pid} = call_graph) do
355 + Agent.cast(pid, fn graph ->
356 + graph
357 + |> Digraph.add_edges(@erlang_mfa_edges)
358 + |> Digraph.add_edges(@dynamic_dispatch_edges)
359 + end)
360 +
361 + call_graph
362 + end
363 +
335 364 @doc """
336 365 Adds the vertex to the call graph.
337 366 """
  @@ -344,14 +373,10 @@ defmodule Hologram.Compiler.CallGraph do
344 373 @doc """
345 374 Builds a call graph from IR.
346 375 """
347 - @spec build(t, IR.t() | list, Context.t()) :: t
376 + @spec build(t, IR.t() | list | map | tuple, vertex | nil) :: t
377 + def build(call_graph, ir, from_vertex \\ nil)
348 378
349 - def build(call_graph, %IR.AtomType{value: value}, %Context{
350 - from_vertex: from_vertex,
351 - guard?: false,
352 - pattern?: false,
353 - modifiers: %{suppress_edges_to_module_vertices?: false}
354 - }) do
379 + def build(call_graph, %IR.AtomType{value: value}, from_vertex) do
355 380 if Reflection.alias?(value) do
356 381 add_edge(call_graph, from_vertex, value)
357 382 end
  @@ -359,250 +384,41 @@ defmodule Hologram.Compiler.CallGraph do
359 384 call_graph
360 385 end
361 386
362 - def build(call_graph, %IR.AtomType{}, _context), do: call_graph
363 -
364 - def build(
365 - call_graph,
366 - %IR.Clause{match: match, guards: guards, body: body},
367 - context
368 - ) do
369 - call_graph
370 - |> build(match, %{context | pattern?: true})
371 - |> build(guards, %{context | guard?: true})
372 - |> build(body, %{context | pattern?: false})
373 - end
374 -
375 - def build(
376 - call_graph,
377 - %IR.FunctionClause{params: params, guards: guards, body: body},
378 - context
379 - ) do
380 - call_graph
381 - |> build(params, %{context | pattern?: true})
382 - |> build(guards, %{context | guard?: true})
383 - |> build(body, %{context | pattern?: false})
384 - end
385 -
386 - # Suppress module vertex edges in __impl__/1 clause body. This function is generated by
387 - # defimpl and its body contains module atoms (the :for and :protocol values) that are
388 - # metadata, not real dependencies. Without this, reaching any protocol implementation
389 - # would pull in the target module's entire function tree via the AtomType handler.
390 - # MFA edges are not affected.
391 - def build(
392 - call_graph,
393 - %IR.FunctionDefinition{name: :__impl__, arity: 1, clause: clause},
394 - %Context{from_vertex: from_vertex} = context
395 - ) do
396 - fun_def_vertex = {from_vertex, :__impl__, 1}
397 -
398 - new_context = %{
399 - context
400 - | from_vertex: fun_def_vertex,
401 - modifiers: %{context.modifiers | suppress_edges_to_module_vertices?: true}
402 - }
403 -
404 - call_graph
405 - |> add_edge(from_vertex, fun_def_vertex)
406 - |> build(clause, new_context)
407 - end
408 -
409 - # Suppress module vertex edges in __protocol__/1 clause body. This function is generated
410 - # by defprotocol and its body contains module atoms (consolidated implementations, the
411 - # protocol module) that are metadata, not real dependencies. Without this, reaching any
412 - # protocol function would pull in every consolidated implementation module's entire
413 - # function tree. MFA edges are not affected.
414 - def build(
415 - call_graph,
416 - %IR.FunctionDefinition{name: :__protocol__, arity: 1, clause: clause},
417 - %Context{from_vertex: from_vertex} = context
418 - ) do
419 - fun_def_vertex = {from_vertex, :__protocol__, 1}
420 -
421 - new_context = %{
422 - context
423 - | from_vertex: fun_def_vertex,
424 - modifiers: %{context.modifiers | suppress_edges_to_module_vertices?: true}
425 - }
426 -
427 - call_graph
428 - |> add_edge(from_vertex, fun_def_vertex)
429 - |> build(clause, new_context)
430 - end
431 -
432 - # Suppress module vertex edges in __struct__/0 and __struct__/1 clause bodies. These
433 - # functions are generated by defstruct and their bodies contain module atoms as default
434 - # field values in the struct map (e.g. calendar: Calendar.ISO in Date). These are data
435 - # defaults, not real dependencies. Without this, any struct with a module atom as a default
436 - # value would pull in that module's entire function tree. MFA edges (Enum.reduce/3,
437 - # Map.merge/2, etc.) are not affected.
438 - def build(
439 - call_graph,
440 - %IR.FunctionDefinition{name: :__struct__, arity: arity, clause: clause},
441 - %Context{from_vertex: from_vertex} = context
442 - )
443 - when arity in [0, 1] do
444 - fun_def_vertex = {from_vertex, :__struct__, arity}
445 -
446 - new_context = %{
447 - context
448 - | from_vertex: fun_def_vertex,
449 - modifiers: %{context.modifiers | suppress_edges_to_module_vertices?: true}
450 - }
451 -
452 - call_graph
453 - |> add_edge(from_vertex, fun_def_vertex)
454 - |> build(clause, new_context)
455 - end
456 -
457 - # Suppress module vertex edges in impl_for/1 clause body. This function is generated by
458 - # defprotocol and its body contains module atoms for ALL implementation modules (return
459 - # values for type-based dispatch). Without this, every implementation module vertex would
460 - # be reached, pulling in their entire function trees. MFA edges (struct_impl_for/1 local
461 - # call in the struct dispatch clause) are not affected. The actual protocol dispatch is
462 - # handled by add_protocol_call_graph_edges/2.
463 - def build(
464 - call_graph,
465 - %IR.FunctionDefinition{name: :impl_for, arity: 1, clause: clause},
466 - %Context{from_vertex: from_vertex} = context
467 - ) do
468 - fun_def_vertex = {from_vertex, :impl_for, 1}
469 -
470 - new_context = %{
471 - context
472 - | from_vertex: fun_def_vertex,
473 - modifiers: %{context.modifiers | suppress_edges_to_module_vertices?: true}
474 - }
475 -
476 - call_graph
477 - |> add_edge(from_vertex, fun_def_vertex)
478 - |> build(clause, new_context)
479 - end
480 -
481 - # Suppress module vertex edges in impl_for!/1 clause body. This function is generated by
482 - # defprotocol and its body contains a self-referential module atom (__MODULE__ in the
483 - # Protocol.UndefinedError message). Without this, reaching impl_for!/1 would pull in
484 - # the protocol module vertex, making ALL protocol functions and ALL their implementations
485 - # reachable. MFA edges (impl_for/1 local call, :erlang.error/1, etc.) are not affected.
486 - # The actual protocol dispatch is handled by add_protocol_call_graph_edges/2.
487 - def build(
488 - call_graph,
489 - %IR.FunctionDefinition{name: :impl_for!, arity: 1, clause: clause},
490 - %Context{from_vertex: from_vertex} = context
491 - ) do
492 - fun_def_vertex = {from_vertex, :impl_for!, 1}
493 -
494 - new_context = %{
495 - context
496 - | from_vertex: fun_def_vertex,
497 - modifiers: %{context.modifiers | suppress_edges_to_module_vertices?: true}
498 - }
499 -
500 - call_graph
501 - |> add_edge(from_vertex, fun_def_vertex)
502 - |> build(clause, new_context)
503 - end
504 -
505 - # Suppress module vertex edges in struct_impl_for/1 clause body. This function is generated
506 - # by defprotocol and its body contains module atoms for ALL struct-based implementation
507 - # modules (both as pattern matches and return values). Without this, every struct
508 - # implementation module vertex would be reached, pulling in their entire function trees.
509 - # MFA edges (Module.concat/2, Code.ensure_compiled/1 in non-consolidated protocols) are
510 - # not affected. The actual protocol dispatch is handled by add_protocol_call_graph_edges/2.
511 - def build(
512 - call_graph,
513 - %IR.FunctionDefinition{name: :struct_impl_for, arity: 1, clause: clause},
514 - %Context{from_vertex: from_vertex} = context
515 - ) do
516 - fun_def_vertex = {from_vertex, :struct_impl_for, 1}
517 -
518 - new_context = %{
519 - context
520 - | from_vertex: fun_def_vertex,
521 - modifiers: %{context.modifiers | suppress_edges_to_module_vertices?: true}
522 - }
523 -
524 - call_graph
525 - |> add_edge(from_vertex, fun_def_vertex)
526 - |> build(clause, new_context)
527 - end
528 -
529 - # Suppress module vertex edges in count/1, member?/2, and slice/1 clause bodies in
530 - # Enumerable protocol implementations. The Enumerable protocol convention requires these
531 - # functions to return {:error, __MODULE__} when deferring to the default implementation.
532 - # The __MODULE__ atom creates a module vertex edge that pulls in all sibling functions
533 - # (including reduce/3 which can cascade heavily). MFA edges are not affected. The actual
534 - # protocol dispatch is already handled by add_protocol_call_graph_edges/2.
535 387 def build(
536 388 call_graph,
537 389 %IR.FunctionDefinition{name: name, arity: arity, clause: clause},
538 - %Context{from_vertex: from_vertex} = context
539 - )
540 - when (name == :count and arity == 1) or
541 - (name == :member? and arity == 2) or
542 - (name == :slice and arity == 1) do
543 - fun_def_vertex = {from_vertex, name, arity}
544 - call_graph = add_edge(call_graph, from_vertex, fun_def_vertex)
545 -
546 - new_context =
547 - if context.protocol_impl == Enumerable do
548 - %{
549 - context
550 - | from_vertex: fun_def_vertex,
551 - modifiers: %{context.modifiers | suppress_edges_to_module_vertices?: true}
552 - }
553 - else
554 - %{context | from_vertex: fun_def_vertex}
555 - end
556 -
557 - build(call_graph, clause, new_context)
558 - end
559 -
560 - def build(
561 - call_graph,
562 - %IR.FunctionDefinition{name: name, arity: arity, clause: clause},
563 - %Context{from_vertex: from_vertex} = context
390 + from_vertex
564 391 ) do
565 392 fun_def_vertex = {from_vertex, name, arity}
566 393
567 394 call_graph
568 - |> add_edge(from_vertex, fun_def_vertex)
569 - |> build(clause, %{context | from_vertex: fun_def_vertex})
395 + |> add_vertex(fun_def_vertex)
396 + |> build(clause, fun_def_vertex)
570 397 end
571 398
572 399 def build(
573 400 call_graph,
574 401 %IR.LocalFunctionCall{function: function, args: args},
575 - %Context{from_vertex: {module, _function, _arity}} = context
402 + {module, _function, _arity} = from_vertex
576 403 ) do
577 404 to_vertex = {module, function, Enum.count(args)}
578 405
579 406 call_graph
580 - |> add_edge(context.from_vertex, to_vertex)
581 - |> build(args, context)
582 - end
583 -
584 - def build(call_graph, %IR.MatchOperator{left: left, right: right}, context) do
585 - call_graph
586 - |> build(left, %{context | pattern?: true})
587 - |> build(right, context)
407 + |> add_edge(from_vertex, to_vertex)
408 + |> build(args, from_vertex)
588 409 end
589 410
590 411 def build(
591 412 call_graph,
592 413 %IR.ModuleDefinition{module: %IR.AtomType{value: module}, body: body},
593 - context
414 + _from_vertex
594 415 ) do
595 - new_context = %{
596 - context
597 - | from_vertex: module,
598 - protocol_impl: Reflection.protocol_impl(module)
599 - }
600 -
601 416 call_graph
417 + |> maybe_add_templatable_call_graph_edges(module)
602 418 |> maybe_add_protocol_call_graph_edges(module)
603 419 |> maybe_add_struct_call_graph_edges(module)
604 420 |> maybe_add_ecto_schema_call_graph_edges(module)
605 - |> build(body, new_context)
421 + |> build(body, module)
606 422 end
607 423
608 424 # :erlang.apply/3 is not added to the call graph because the encoder
  @@ -618,12 +434,12 @@ defmodule Hologram.Compiler.CallGraph do
618 434 %IR.ListType{data: args}
619 435 ]
620 436 },
621 - context
437 + from_vertex
622 438 ) do
623 439 to_vertex = {module, function, Enum.count(args)}
624 - add_edge(call_graph, context.from_vertex, to_vertex)
440 + add_edge(call_graph, from_vertex, to_vertex)
625 441
626 - build(call_graph, args, context)
442 + build(call_graph, args, from_vertex)
627 443 end
628 444
629 445 # :erlang.apply/3 is not added to the call graph because the encoder
  @@ -635,153 +451,12 @@ defmodule Hologram.Compiler.CallGraph do
635 451 function: :apply,
636 452 args: [module, function, %IR.ListType{data: args}]
637 453 },
638 - context
454 + from_vertex
639 455 ) do
640 456 call_graph
641 - |> build(module, context)
642 - |> build(function, context)
643 - |> build(args, context)
644 - end
645 -
646 - # Suppress module vertex edges in the third argument (error options) of :erlang.error/3.
647 - # The Elixir compiler transforms `raise` into :erlang.error/3 with
648 - # error_info: %{module: Exception} as the third argument. This Exception
649 - # module atom is compiler-injected metadata for error formatting, not a
650 - # real dependency. Without this, every `raise` would pull in the Exception
651 - # module's entire function tree (58 functions). MFA edges are not affected.
652 - def build(
653 - call_graph,
654 - %IR.RemoteFunctionCall{
655 - module: %IR.AtomType{value: :erlang},
656 - function: :error,
657 - args: [reason, args, error_options]
658 - },
659 - context
660 - ) do
661 - add_edge(call_graph, context.from_vertex, {:erlang, :error, 3})
662 -
663 - suppressed_context = %{
664 - context
665 - | modifiers: %{context.modifiers | suppress_edges_to_module_vertices?: true}
666 - }
667 -
668 - call_graph
669 - |> build(reason, context)
670 - |> build(args, context)
671 - |> build(error_options, suppressed_context)
672 - end
673 -
674 - # Skip module atoms in the first argument (deduplication key) of IO.warn_once/3.
675 - # The key is typically a tuple like {ModuleName, some_value} used to track
676 - # whether a warning has already been printed. The module atom in the key is
677 - # a namespace identifier, not a real dependency. Without this, any module
678 - # calling IO.warn_once with its own name as a key would pull in its entire
679 - # function tree via the module vertex.
680 - # Non-atom elements in the key are still traversed to capture any function call edges.
681 - def build(
682 - call_graph,
683 - %IR.RemoteFunctionCall{
684 - module: %IR.AtomType{value: IO},
685 - function: :warn_once,
686 - args: [key, message, stacktrace_depth]
687 - },
688 - context
689 - ) do
690 - add_edge(call_graph, context.from_vertex, {IO, :warn_once, 3})
691 -
692 - suppressed_context = %{
693 - context
694 - | modifiers: %{context.modifiers | suppress_edges_to_module_vertices?: true}
695 - }
696 -
697 - call_graph
698 - |> build(key, suppressed_context)
699 - |> build(message, context)
700 - |> build(stacktrace_depth, context)
701 - end
702 -
703 - # Suppress module vertex edges in Kernel.inspect/1,2 first argument. Module atoms passed
704 - # to inspect are used for string formatting (e.g. in error messages), not as real
705 - # dependencies. Without this, code like `inspect(MyModule)` in error paths would pull in
706 - # MyModule's entire function tree. MFA edges are not affected. The second argument
707 - # (options) is traversed normally.
708 - def build(
709 - call_graph,
710 - %IR.RemoteFunctionCall{
711 - module: %IR.AtomType{value: Kernel},
712 - function: :inspect,
713 - args: [term | opts]
714 - },
715 - context
716 - ) do
717 - to_vertex = {Kernel, :inspect, 1 + length(opts)}
718 - add_edge(call_graph, context.from_vertex, to_vertex)
719 -
720 - suppressed_context = %{
721 - context
722 - | modifiers: %{context.modifiers | suppress_edges_to_module_vertices?: true}
723 - }
724 -
725 - call_graph
726 - |> build(term, suppressed_context)
727 - |> build(opts, context)
728 - end
729 -
730 - # For Kernel.struct!/2 with a literal module atom as the first argument, create targeted
731 - # edges to {module, :__struct__, 0} and {module, :__struct__, 1} instead of the module
732 - # vertex. Kernel.struct!/2 only uses the module to call __struct__/0 and __struct__/1.
733 - # This is the same approach as the __struct__ key-in-map special case. Without this,
734 - # every defexception module's exception/1 function (which calls Kernel.struct!(__MODULE__,
735 - # args)) would pull in the module's entire function tree.
736 - def build(
737 - call_graph,
738 - %IR.RemoteFunctionCall{
739 - module: %IR.AtomType{value: Kernel},
740 - function: :struct!,
741 - args: [%IR.AtomType{value: module}, fields]
742 - },
743 - context
744 - ) do
745 - call_graph
746 - |> add_edge(context.from_vertex, {Kernel, :struct!, 2})
747 - |> add_edge(context.from_vertex, {module, :__struct__, 0})
748 - |> add_edge(context.from_vertex, {module, :__struct__, 1})
749 - |> build(fields, context)
750 - end
751 -
752 - # Suppress module vertex edges in the :protocol key value of
753 - # Protocol.UndefinedError.exception/1 args. This is expanded from:
754 - # raise Protocol.UndefinedError, protocol: SomeProtocol, value: data
755 - # The protocol module atom is just metadata identifying the protocol in the error message,
756 - # not a real dependency. Without this, raising Protocol.UndefinedError in protocol
757 - # implementation fallback clauses would pull in the protocol module's entire function tree.
758 - # MFA edges are not affected. Other keyword entries (e.g. :value, :description) are
759 - # traversed normally.
760 - def build(
761 - call_graph,
762 - %IR.RemoteFunctionCall{
763 - module: %IR.AtomType{value: Protocol.UndefinedError},
764 - function: :exception,
765 - args: [%IR.ListType{data: data}]
766 - },
767 - context
768 - ) do
769 - add_edge(call_graph, context.from_vertex, {Protocol.UndefinedError, :exception, 1})
770 -
771 - suppressed_context = %{
772 - context
773 - | modifiers: %{context.modifiers | suppress_edges_to_module_vertices?: true}
774 - }
775 -
776 - Enum.each(data, fn
777 - %IR.TupleType{data: [%IR.AtomType{value: :protocol}, module]} ->
778 - build(call_graph, module, suppressed_context)
779 -
780 - entry ->
781 - build(call_graph, entry, context)
782 - end)
783 -
784 - call_graph
457 + |> build(module, from_vertex)
458 + |> build(function, from_vertex)
459 + |> build(args, from_vertex)
785 460 end
786 461
787 462 def build(
  @@ -791,84 +466,40 @@ defmodule Hologram.Compiler.CallGraph do
791 466 function: function,
792 467 args: args
793 468 },
794 - context
469 + from_vertex
795 470 ) do
796 471 to_vertex = {module, function, Enum.count(args)}
797 - add_edge(call_graph, context.from_vertex, to_vertex)
472 + add_edge(call_graph, from_vertex, to_vertex)
798 473
799 - build(call_graph, args, context)
474 + build(call_graph, args, from_vertex)
800 475 end
801 476
802 - def build(
803 - call_graph,
804 - %IR.TryCatchClause{kind: kind, value: value, guards: guards, body: body},
805 - context
806 - ) do
807 - call_graph
808 - |> build(kind, context)
809 - |> build(value, %{context | pattern?: true})
810 - |> build(guards, %{context | guard?: true})
811 - |> build(body, context)
812 - end
813 -
814 - # Rescue clause exception modules are used for pattern matching against exception types,
815 - # not as module dependencies. Without this, rescuing UndefinedFunctionError (as Access.get/3
816 - # and Access.fetch/2 do) would pull in the entire UndefinedFunctionError function tree
817 - # (blame/2, hint/4, exports_for/1, etc.).
818 - def build(
819 - call_graph,
820 - %IR.TryRescueClause{variable: variable, modules: modules, body: body},
821 - context
822 - ) do
823 - call_graph
824 - |> build(variable, %{context | pattern?: true})
825 - |> build(modules, %{context | pattern?: true})
826 - |> build(body, context)
827 - end
828 -
829 - def build(call_graph, list, context) when is_list(list) do
830 - Enum.each(list, &build(call_graph, &1, context))
477 + def build(call_graph, list, from_vertex) when is_list(list) do
478 + Enum.each(list, &build(call_graph, &1, from_vertex))
831 479 call_graph
832 480 end
833 481
834 - def build(call_graph, map, context) when is_map(map) do
482 + def build(call_graph, map, from_vertex) when is_map(map) do
835 483 map
836 484 |> Map.to_list()
837 485 |> Enum.each(fn {key, value} ->
838 - build(call_graph, key, context)
839 - build(call_graph, value, context)
486 + call_graph
487 + |> build(key, from_vertex)
488 + |> build(value, from_vertex)
840 489 end)
841 490
842 491 call_graph
843 492 end
844 493
845 - # For __struct__ key in map data, create targeted edges to __struct__/0 and __struct__/1
846 - # instead of to the module vertex, to avoid pulling in the module's entire function tree.
847 - def build(call_graph, {%IR.AtomType{value: :__struct__}, %IR.AtomType{value: module}}, %Context{
848 - from_vertex: from_vertex,
849 - guard?: false,
850 - pattern?: false
851 - }) do
852 - if Reflection.alias?(module) do
853 - call_graph
854 - |> add_edge(from_vertex, {module, :__struct__, 0})
855 - |> add_edge(from_vertex, {module, :__struct__, 1})
856 - else
857 - call_graph
858 - end
859 - end
860 -
861 - def build(call_graph, {%IR.AtomType{value: :__struct__}, _value}, _context), do: call_graph
862 -
863 - def build(call_graph, tuple, context) when is_tuple(tuple) do
494 + def build(call_graph, tuple, from_vertex) when is_tuple(tuple) do
864 495 tuple
865 496 |> Tuple.to_list()
866 - |> Enum.each(&build(call_graph, &1, context))
497 + |> Enum.each(&build(call_graph, &1, from_vertex))
867 498
868 499 call_graph
869 500 end
870 501
871 - def build(call_graph, _ir, _context), do: call_graph
502 + def build(call_graph, _ir, _from_vertex), do: call_graph
872 503
873 504 @doc """
874 505 Builds a call graph from a module definition IR located in the given IR PLT.
  @@ -876,7 +507,7 @@ defmodule Hologram.Compiler.CallGraph do
876 507 @spec build_for_module(t, PLT.t(), module) :: t
877 508 def build_for_module(call_graph, ir_plt, module) do
878 509 module_def = PLT.get!(ir_plt, module)
879 - build(call_graph, module_def, %Context{})
510 + build(call_graph, module_def)
880 511 end
881 512
882 513 @doc """
  @@ -890,32 +521,6 @@ defmodule Hologram.Compiler.CallGraph do
890 521 start(graph)
891 522 end
892 523
893 - @doc """
894 - Computes cascade entries for module vertices in a call graph.
895 -
896 - Returns a list of `{source, module, downstream_mfa_count}` tuples sorted by
897 - downstream MFA count (biggest cascades first).
898 - """
899 - @spec compute_cascades(Digraph.t(), MapSet.t(), MapSet.t()) :: [
900 - {vertex, vertex, non_neg_integer}
901 - ]
902 - def compute_cascades(graph, module_vertices, reachable) do
903 - module_vertices
904 - |> Enum.flat_map(fn module ->
905 - downstream_mfa_count =
906 - graph
907 - |> Digraph.reachable([module])
908 - |> Enum.count(&match?({_module, _function, _arity}, &1))
909 -
910 - graph
911 - |> Digraph.incoming_edges(module)
912 - |> Enum.map(&elem(&1, 0))
913 - |> Enum.filter(&MapSet.member?(reachable, &1))
914 - |> Enum.map(fn source -> {source, module, downstream_mfa_count} end)
915 - end)
916 - |> Enum.sort_by(fn {_source, _module, count} -> count end, :desc)
917 - end
918 -
919 524 @doc """
920 525 Serializes the call graph and writes it to a file.
921 526
  @@ -953,18 +558,6 @@ defmodule Hologram.Compiler.CallGraph do
953 558 Agent.get(pid, & &1, :infinity)
954 559 end
955 560
956 - @doc """
957 - Returns the underlying digraph pruned to the given page, with server-only
958 - and other-page MFAs removed.
959 - """
960 - @spec get_pruned_page_graph(t, module) :: Digraph.t()
961 - def get_pruned_page_graph(call_graph, page_module) do
962 - call_graph
963 - |> get_graph()
964 - |> remove_server_only_mfas()
965 - |> remove_other_page_mfas(page_module)
966 - end
967 -
968 561 @doc """
969 562 Checks if an edge exists between two given vertices in the call graph.
970 563 """
  @@ -992,7 +585,7 @@ defmodule Hologram.Compiler.CallGraph do
992 585 graph = get_graph(call_graph)
993 586
994 587 graph
995 - |> Digraph.reaching([{Task, :await, 1}], skip_module_vertices: true)
588 + |> Digraph.reaching([{Task, :await, 1}], opaque_vertex?: &is_atom/1)
996 589 # Excludes bare module atom vertices, keeping only MFA tuples.
997 590 # No Reflection.module?/1 guard needed in the filter (unlike reachable_mfas/2) because
998 591 # the result is only used for MapSet.member? lookups against already-included MFAs.
  @@ -1000,16 +593,48 @@ defmodule Hologram.Compiler.CallGraph do
1000 593 |> MapSet.new()
1001 594 end
1002 595
596 + @doc """
597 + Lists the entry MFAs {module, function, arity} for a given page module.
598 +
599 + This function returns a list of MFAs that are considered entry points for a page,
600 + including functions from both the page module and its associated layout module.
601 +
602 + ## Parameters
603 +
604 + * `page_module` - The module of the page for which to list entry MFAs.
605 +
606 + ## Returns
607 +
608 + A list of MFAs (tuples of {module, function, arity}) that serve as entry points
609 + for the given page module and its layout.
610 + """
611 + @spec list_page_entry_mfas(module) :: [mfa]
612 + def list_page_entry_mfas(page_module) do
613 + layout_module = page_module.__layout_module__()
614 +
615 + [
616 + {page_module, :__layout_module__, 0},
617 + {page_module, :__layout_props__, 0},
618 + {page_module, :__params__, 0},
619 + {page_module, :__route__, 0},
620 + {page_module, :action, 3},
621 + {page_module, :template, 0},
622 + {layout_module, :__props__, 0},
623 + {layout_module, :action, 3},
624 + {layout_module, :template, 0}
625 + ]
626 + end
627 +
1003 628 @doc """
1004 629 Returns the sorted list of MFAs that are reachable by the given page.
1005 630 """
1006 631 @spec list_page_mfas(t, module) :: [mfa]
1007 632 def list_page_mfas(call_graph, page_module) do
633 + entry_mfas = list_page_entry_mfas(page_module)
1008 634 graph = get_graph(call_graph)
1009 635
1010 - call_graph
1011 - |> get_pruned_page_graph(page_module)
1012 - |> sorted_reachable_mfas([page_module])
636 + graph
637 + |> sorted_reachable_mfas(entry_mfas)
1013 638 |> reject_hex_mfas()
1014 639 |> add_reflection_mfas_reachable_from_server_inits(page_module, graph)
1015 640 |> Enum.uniq()
  @@ -1024,12 +649,9 @@ defmodule Hologram.Compiler.CallGraph do
1024 649 @spec list_runtime_entry_mfas :: [mfa]
1025 650 def list_runtime_entry_mfas do
1026 651 @mfas_used_by_client_runtime
1027 - |> Enum.reduce(
1028 - @mfas_used_by_all_pages_and_components,
1029 - fn {_key, mfas}, acc ->
1030 - mfas ++ acc
1031 - end
1032 - )
652 + |> Enum.reduce(@mfas_used_by_all_pages_and_components, fn {_key, mfas}, acc ->
653 + mfas ++ acc
654 + end)
1033 655 |> Enum.uniq()
1034 656 |> Enum.sort()
1035 657 end
  @@ -1045,7 +667,6 @@ defmodule Hologram.Compiler.CallGraph do
1045 667
1046 668 call_graph
1047 669 |> get_graph()
1048 - |> add_edges_for_erlang_functions()
1049 670 |> sorted_reachable_mfas(entry_mfas)
1050 671 |> reject_hex_mfas()
1051 672 end
  @@ -1309,9 +930,24 @@ defmodule Hologram.Compiler.CallGraph do
1309 930 Agent.get(pid, &Digraph.vertices/1, :infinity)
1310 931 end
1311 932
1312 - # Add call graph edges for Erlang functions depending on other Erlang functions.
1313 - defp add_edges_for_erlang_functions(graph) do
1314 - Digraph.add_edges(graph, @erlang_mfa_edges)
933 + # TODO: think how to avoid this
934 + # A component module can be passed as a prop to another component, allowing dynamic usage.
935 + # In such cases, when this scenario is identified, it becomes necessary
936 + # to include the entire component on the client side.
937 + # This is because we lack precise information about which specific component functions will be used.
938 + defp add_component_call_graph_edges(call_graph, module) do
939 + call_graph
940 + |> add_edge(module, {module, :__props__, 0})
941 + |> add_edge(module, {module, :action, 3})
942 + |> add_edge(module, {module, :init, 2})
943 + |> add_edge(module, {module, :template, 0})
944 + end
945 +
946 + # __props__/0 and __route__/0 functions are needed to build page link href (e.g. in Hologram.UI.Link component).
947 + defp add_page_call_graph_edges(call_graph, module) do
948 + call_graph
949 + |> add_edge(module, {module, :__params__, 0})
950 + |> add_edge(module, {module, :__route__, 0})
1315 951 end
1316 952
1317 953 defp add_protocol_call_graph_edges(call_graph, module) do
  @@ -1406,6 +1042,18 @@ defmodule Hologram.Compiler.CallGraph do
1406 1042 call_graph
1407 1043 end
1408 1044
1045 + defp maybe_add_templatable_call_graph_edges(call_graph, module) do
1046 + if Reflection.page?(module) do
1047 + add_page_call_graph_edges(call_graph, module)
1048 + end
1049 +
1050 + if Reflection.component?(module) do
1051 + add_component_call_graph_edges(call_graph, module)
1052 + end
1053 +
1054 + call_graph
1055 + end
1056 +
1409 1057 # When modules that are protocol implementations are added or edited, the protocol
1410 1058 # module itself (e.g. Enumerable) is unchanged and not re-processed by patch. Its
1411 1059 # dispatch edges remain stale. This function re-runs add_protocol_call_graph_edges
  @@ -1423,38 +1071,4 @@ defmodule Hologram.Compiler.CallGraph do
1423 1071 defp remove_module_vertices(call_graph, module) do
1424 1072 remove_vertices(call_graph, module_vertices(call_graph, module))
1425 1073 end
1426 -
1427 - defp remove_other_page_mfas(graph, page_module) do
1428 - other_page_modules =
1429 - Reflection.list_pages()
1430 - |> Enum.reject(&(&1 == page_module))
1431 - |> MapSet.new()
1432 -
1433 - other_page_vertices =
1434 - graph
1435 - |> Digraph.vertices()
1436 - |> Enum.filter(fn
1437 - {module, fun, arity} ->
1438 - MapSet.member?(other_page_modules, module) &&
1439 - {fun, arity} not in @cross_page_funs
1440 -
1441 - _fallback ->
1442 - false
1443 - end)
1444 -
1445 - Digraph.remove_vertices(graph, other_page_vertices)
1446 - end
1447 -
1448 - defp remove_server_only_mfas(graph) do
1449 - server_only_vertices =
1450 - graph
1451 - |> Digraph.vertices()
1452 - |> Enum.filter(fn
1453 - {module, :command, 3} -> Reflection.templatable?(module)
1454 - {module, :init, 3} -> Reflection.templatable?(module)
1455 - _fallback -> false
1456 - end)
1457 -
1458 - Digraph.remove_vertices(graph, server_only_vertices)
1459 - end
1460 1074 end
  @@ -205,11 +205,13 @@ defmodule Hologram.Compiler.Digraph do
205 205 Non-existent target vertices are ignored.
206 206
207 207 ## Options
208 + * `:opaque_vertex?` - a one-arity function that receives a vertex and returns a boolean.
209 + When it returns `true`, the vertex is included in the result but its incoming edges
210 + are not traversed. Defaults to `nil`.
208 211
209 - * `:skip_module_vertices` - when `true`, module vertices (atoms) are added to the
210 - result but their incoming edges are not traversed. This prevents module atom
211 - references from propagating through module-to-function-definition edges.
212 - Defaults to `false`.
212 + * `:opaque_vertices` - a `MapSet` of vertices whose incoming edges should not be
213 + traversed. The vertices themselves are still included in the result when reached,
214 + but the BFS does not follow their incoming edges. Defaults to `nil`.
213 215 """
214 216 @spec reaching(t, [vertex], keyword) :: [vertex]
215 217 def reaching(graph, target_vertices, opts \\ []) do
  @@ -449,12 +451,19 @@ defmodule Hologram.Compiler.Digraph do
449 451 end
450 452
451 453 # BFS traversal for reachable vertices
452 - # credo:disable-for-lines:31 Credo.Check.Refactor.Nesting
454 + # credo:disable-for-lines:37 Credo.Check.Refactor.Nesting
453 455 # The above Credo check is disabled because the function is optimised this way
454 456 defp bfs_reachable(queue, visited, edges, opts) do
455 457 case :queue.out(queue) do
456 458 {{:value, current}, rest_queue} ->
457 - if opts[:skip_module_vertices] && is_atom(current) do
459 + opaque_vertex? = opts[:opaque_vertex?]
460 + opaque_vertices = opts[:opaque_vertices]
461 +
462 + skip? =
463 + (opaque_vertex? && opaque_vertex?.(current)) ||
464 + (opaque_vertices && MapSet.member?(opaque_vertices, current))
465 +
466 + if skip? do
458 467 bfs_reachable(rest_queue, visited, edges, opts)
459 468 else
460 469 # Get neighbors of current vertex
Loading more files…