diff --git a/CHANGELOG.md b/CHANGELOG.md index 839cff8..61e5c8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### New +- **LiveView/HEEx lowering** — added an optional LiveView plugin that recognizes `~H` and `.heex` templates, lowers HEEx control flow (`if`/`case`, `:if`, `:for`) into Reach's Elixir IR with template source labels/spans, and hides LiveView rendering helper edges from graph presentation. The plugin uses LiveView's parser when available and falls back to the stable `TagEngine` path for current 1.1.x projects. +- **LiveView semantic edges** — LiveView analysis now connects `JS.push`/`push_event` calls to `handle_event/3`, assign writes to HEEx assign reads, parser-lowered component attr values to component calls, and stream writes to `@streams.*` reads. Static `phx-*` event attrs are modeled by the parser-backed HEEx lowerer when available. - **Architecture layer ergonomics** — `.reach.exs` now validates unknown layer references, supports allowlist-style dependency policy, dependency exceptions, optional layer coverage checks, and layer-cycle violations with concrete call-edge witnesses. ### Changed diff --git a/lib/reach.ex b/lib/reach.ex index a194737..a3097ea 100644 --- a/lib/reach.ex +++ b/lib/reach.ex @@ -97,7 +97,7 @@ defmodule Reach do """ @spec file_to_graph(Path.t(), keyword()) :: {:ok, graph()} | {:error, term()} def file_to_graph(path, opts \\ []) do - language = Keyword.get(opts, :language) || language_from_extension(path) + language = Keyword.get(opts, :language) || language_from_extension(path, opts) opts = Keyword.put_new(opts, :file, path) |> Keyword.put(:language, language) case language do @@ -1151,11 +1151,11 @@ defmodule Reach do end end - defp language_from_extension(path) do + defp language_from_extension(path, opts) do case Path.extname(path) do ext when ext in [".erl", ".hrl"] -> :erlang ".gleam" -> :gleam - ext -> Plugin.source_language(Plugin.detect(), ext) || :elixir + ext -> Plugin.source_language(Plugin.resolve(opts), ext) || :elixir end end end diff --git a/lib/reach/cli/render/check/smells.ex b/lib/reach/cli/render/check/smells.ex index 90ee217..a8385bd 100644 --- a/lib/reach/cli/render/check/smells.ex +++ b/lib/reach/cli/render/check/smells.ex @@ -39,12 +39,39 @@ defmodule Reach.CLI.Render.Check.Smells do render_group(structural_consistency(grouped), "Structural consistency") render_group(Map.get(grouped, :behaviour_candidate, []), "Behaviour candidates") render_group(Map.get(grouped, :dual_key_access, []), "Loose map contracts") + rendered_kinds = rendered_kinds() + render_group(Map.get(grouped, :fixed_shape_map, []), "Repeated map shapes") - IO.puts("#{length(findings)} finding(s)\n") + grouped + |> Map.drop(rendered_kinds) + |> Enum.sort_by(fn {kind, _findings} -> to_string(kind) end) + |> Enum.each(fn {kind, findings} -> + render_group(findings, Format.humanize(kind)) + end) + + IO.puts(" #{length(findings)} finding(s)\n") end end + defp rendered_kinds do + [ + :redundant_traversal, + :suboptimal, + :redundant_computation, + :eager_pattern, + :string_building, + :config_phase, + :return_contract_drift, + :side_effect_order_drift, + :map_contract_drift, + :validation_drift, + :behaviour_candidate, + :dual_key_access, + :fixed_shape_map + ] + end + defp structural_consistency(grouped) do [:return_contract_drift, :side_effect_order_drift, :map_contract_drift, :validation_drift] |> Enum.flat_map(&Map.get(grouped, &1, [])) diff --git a/lib/reach/frontend/elixir.ex b/lib/reach/frontend/elixir.ex index 5c38640..91f2a0b 100644 --- a/lib/reach/frontend/elixir.ex +++ b/lib/reach/frontend/elixir.ex @@ -7,6 +7,7 @@ defmodule Reach.Frontend.Elixir do """ alias Reach.IR.{Counter, Node} + alias Reach.{Plugin, Source} import Reach.IR.Helpers, only: [mark_as_definitions: 1] @doc """ @@ -24,8 +25,16 @@ defmodule Reach.Frontend.Elixir do ) do {:ok, ast} -> counter = Keyword.get(opts, :counter, Counter.new()) - nodes = translate(ast, counter, file) - {:ok, List.wrap(nodes)} + plugins = Plugin.resolve(opts) + previous_plugins = Process.get(:reach_plugins) + Process.put(:reach_plugins, plugins) + + try do + nodes = translate(ast, counter, file) + {:ok, List.wrap(nodes)} + after + restore_process_value(:reach_plugins, previous_plugins) + end {:error, _} = err -> err @@ -111,6 +120,7 @@ defmodule Reach.Frontend.Elixir do %Node{ id: Counter.next(counter), type: :block, + meta: origin_meta(meta), children: children, source_span: span_from_meta(meta, file) } @@ -215,7 +225,7 @@ defmodule Reach.Frontend.Elixir do %Node{ id: Counter.next(counter), type: :case, - meta: %{desugared_from: kind}, + meta: Map.merge(%{desugared_from: kind}, origin_meta(meta)), children: [condition_node, true_clause, false_clause], source_span: span_from_meta(meta, file) } @@ -241,7 +251,7 @@ defmodule Reach.Frontend.Elixir do %Node{ id: Counter.next(counter), type: :case, - meta: %{desugared_from: :cond}, + meta: Map.merge(%{desugared_from: :cond}, origin_meta(meta)), children: children, source_span: span_from_meta(meta, file) } @@ -269,6 +279,7 @@ defmodule Reach.Frontend.Elixir do %Node{ id: Counter.next(counter), type: :case, + meta: origin_meta(meta), children: [expr_node | clause_nodes], source_span: span_from_meta(meta, file) } @@ -337,7 +348,7 @@ defmodule Reach.Frontend.Elixir do %Node{ id: Counter.next(counter), type: :case, - meta: %{desugared_from: :with}, + meta: Map.merge(%{desugared_from: :with}, origin_meta(meta)), children: clause_nodes ++ [body_node | else_nodes], source_span: span_from_meta(meta, file) } @@ -458,6 +469,7 @@ defmodule Reach.Frontend.Elixir do %Node{ id: Counter.next(counter), type: :generator, + meta: origin_meta(clause_meta), children: [pat_node, enum_node], source_span: span_from_meta(clause_meta, file) } @@ -469,7 +481,7 @@ defmodule Reach.Frontend.Elixir do %Node{ id: Counter.next(counter), type: :generator, - meta: %{kind: :binary}, + meta: Map.merge(%{kind: :binary}, origin_meta(clause_meta)), children: [pat_node, enum_node], source_span: span_from_meta(clause_meta, file) } @@ -483,13 +495,14 @@ defmodule Reach.Frontend.Elixir do end) body_node = translate(Keyword.get(opts, :do), counter, file) + children = clause_nodes ++ [body_node] %Node{ id: Counter.next(counter), type: :comprehension, - meta: Map.new(Keyword.drop(opts, [:do])), - children: clause_nodes ++ [body_node], - source_span: span_from_meta(meta, file) + meta: Map.merge(Map.new(Keyword.drop(opts, [:do])), origin_meta(meta)), + children: children, + source_span: span_from_meta(meta, file) || Enum.find_value(children, &first_child_span/1) } end @@ -708,7 +721,9 @@ defmodule Reach.Frontend.Elixir do %Node{ id: Counter.next(counter), type: :call, - meta: %{module: resolved_module, function: fun_name, kind: :remote}, + meta: + %{module: resolved_module, function: fun_name, kind: :remote} + |> Map.merge(origin_meta(meta)), children: receiver_children, source_span: span_from_meta(meta, file) } @@ -723,7 +738,7 @@ defmodule Reach.Frontend.Elixir do %Node{ id: Counter.next(counter), type: :call, - meta: %{arity: length(args), kind: :dynamic}, + meta: %{arity: length(args), kind: :dynamic} |> Map.merge(origin_meta(call_meta || meta)), children: [callee_node | arg_nodes], source_span: span_from_meta(call_meta || meta, file) } @@ -753,7 +768,9 @@ defmodule Reach.Frontend.Elixir do %Node{ id: Counter.next(counter), type: :call, - meta: %{module: resolved_module, function: field, arity: 0, kind: :remote}, + meta: + %{module: resolved_module, function: field, arity: 0, kind: :remote} + |> Map.merge(origin_meta(call_meta || meta)), children: receiver_children, source_span: span_from_meta(call_meta || meta, file) } @@ -786,32 +803,42 @@ defmodule Reach.Frontend.Elixir do %Node{ id: Counter.next(counter), type: :call, - meta: %{ - module: resolved_module, - function: fun_name, - arity: length(args), - kind: :remote - }, + meta: + %{ + module: resolved_module, + function: fun_name, + arity: length(args), + kind: :remote + } + |> Map.merge(origin_meta(call_meta || meta)), children: receiver_children ++ arg_nodes, source_span: span_from_meta(call_meta || meta, file) } end # Local call: function(args) — check if imported - defp translate({fun_name, meta, args}, counter, file) + defp translate({fun_name, meta, args} = ast, counter, file) when is_atom(fun_name) and is_list(args) do - arg_nodes = Enum.map(args, &translate(&1, counter, file)) - arity = length(args) + case lower_elixir_ast(ast, file) do + {:ok, lowered_ast} -> + translate(lowered_ast, counter, file) - {module, kind} = resolve_import(fun_name, arity) + _ -> + arg_nodes = Enum.map(args, &translate(&1, counter, file)) + arity = length(args) - %Node{ - id: Counter.next(counter), - type: :call, - meta: %{function: fun_name, arity: arity, module: module, kind: kind}, - children: arg_nodes, - source_span: span_from_meta(meta, file) - } + {module, kind} = resolve_import(fun_name, arity) + + %Node{ + id: Counter.next(counter), + type: :call, + meta: + %{function: fun_name, arity: arity, module: module, kind: kind} + |> Map.merge(origin_meta(meta)), + children: arg_nodes, + source_span: span_from_meta(meta, file) + } + end end # Catch-all for unhandled AST forms @@ -1281,20 +1308,39 @@ defmodule Reach.Frontend.Elixir do end defp span_from_meta(meta, file) when is_list(meta) do - case meta[:line] do - nil -> - nil + Source.span_from_origin(Source.origin(meta)) || + case meta[:line] do + nil -> + nil + + line -> + %{ + file: file, + start_line: line, + start_col: meta[:column] || 1, + end_line: nil, + end_col: nil + } + end + end - line -> - %{ - file: file, - start_line: line, - start_col: meta[:column] || 1, - end_line: nil, - end_col: nil - } + defp span_from_meta(_, _), do: nil + + defp origin_meta(meta) when is_list(meta) do + case Source.origin(meta) do + nil -> %{} + origin -> %{origin: origin} end end - defp span_from_meta(_, _), do: nil + defp origin_meta(_), do: %{} + + defp lower_elixir_ast(ast, file) do + plugins = Process.get(:reach_plugins, []) + opts = [file: file, module: Process.get(:reach_current_module)] + Plugin.lower_elixir_ast(plugins, ast, opts) + end + + defp restore_process_value(key, nil), do: Process.delete(key) + defp restore_process_value(key, value), do: Process.put(key, value) end diff --git a/lib/reach/plugin.ex b/lib/reach/plugin.ex index 7cf7e3d..6f68b01 100644 --- a/lib/reach/plugin.ex +++ b/lib/reach/plugin.ex @@ -14,11 +14,14 @@ defmodule Reach.Plugin do 3. **Source frontends** — optional `source_extensions/0` and `parse_file/2` callbacks let plugins own language frontends for optional ecosystems. - 4. **Embedded IR** — `analyze_embedded/2` extracts code from string + 4. **Elixir AST lowering** — `lower_elixir_ast/2` translates framework + DSL forms, sigils, or template syntax into analysis-friendly Elixir AST. + + 5. **Embedded IR** — `analyze_embedded/2` extracts code from string literals (e.g. JS inside QuickBEAM.eval) and returns additional IR nodes plus cross-language edges. - 5. **Framework presentation/patterns** — optional callbacks provide + 6. **Framework presentation/patterns** — optional callbacks provide framework-specific trace presets, behaviour labels, and visualization edge filtering. @@ -84,6 +87,16 @@ defmodule Reach.Plugin do connecting them to the host graph. """ @callback analyze_embedded(all_nodes :: [Node.t()], opts :: keyword()) :: embedded_result() + + @doc """ + Lowers framework-specific Elixir AST into ordinary Elixir AST before Reach IR translation. + + Return `:ignore` when the plugin does not own the AST node. Returned AST may + carry `%Reach.Source.Origin{}` metadata under the `:reach` metadata key. + """ + @callback lower_elixir_ast(ast :: Macro.t(), opts :: keyword()) :: + {:ok, Macro.t()} | :ignore | {:error, term()} + @callback source_extensions() :: [String.t()] @callback source_language(extension :: String.t()) :: atom() | nil @callback parse_file(path :: Path.t(), opts :: keyword()) :: @@ -100,6 +113,7 @@ defmodule Reach.Plugin do @optional_callbacks analyze_project: 3, classify_effect: 1, analyze_embedded: 2, + lower_elixir_ast: 2, source_extensions: 0, source_language: 1, parse_file: 2, @@ -112,6 +126,7 @@ defmodule Reach.Plugin do @known_plugins [ {Phoenix.Router, Reach.Plugins.Phoenix}, + {Phoenix.LiveView, Reach.Plugins.LiveView}, {Ecto, Reach.Plugins.Ecto}, {Ash, Reach.Plugins.Ash}, {Oban, Reach.Plugins.Oban}, @@ -162,6 +177,20 @@ defmodule Reach.Plugin do end) end + @doc "Asks plugins to lower a framework-specific Elixir AST node." + def lower_elixir_ast(plugins, ast, opts) do + Enum.find_value(plugins, :ignore, &lower_elixir_ast_with_plugin(&1, ast, opts)) + end + + defp lower_elixir_ast_with_plugin(plugin, ast, opts) do + if exports?(plugin, :lower_elixir_ast, 2) do + case plugin.lower_elixir_ast(ast, opts) do + :ignore -> nil + result -> result + end + end + end + @doc "Returns source extensions handled by plugins." def source_extensions(plugins) do plugins diff --git a/lib/reach/plugins/live_view.ex b/lib/reach/plugins/live_view.ex new file mode 100644 index 0000000..f20e031 --- /dev/null +++ b/lib/reach/plugins/live_view.ex @@ -0,0 +1,304 @@ +defmodule Reach.Plugins.LiveView do + @moduledoc "Plugin for LiveView and HEEx template semantics." + @behaviour Reach.Plugin + + alias Reach.IR + alias Reach.IR.Node + alias Reach.Plugins.LiveView.HEEx + + @pure_local [ + :assign, + :assign_new, + :push_event, + :push_patch, + :push_navigate, + :put_flash, + :redirect, + :live_render, + :live_component, + :on_mount, + :__live_event__, + :sigil_H, + :sigil_p + ] + + @pure_remote_modules [Phoenix.Component, Phoenix.LiveView] + + @assign_modules [nil, Phoenix.Component, Phoenix.LiveView] + @event_attrs [:__live_event__] + @event_functions [:push_event] + @stream_functions [:stream, :stream_insert, :stream_delete] + + @impl true + def analyze(all_nodes, _opts) do + function_defs = Enum.filter(all_nodes, &(&1.type == :function_def)) + + live_event_edges(function_defs) ++ + live_assign_edges(function_defs) ++ + live_component_attr_edges(function_defs) ++ + live_stream_edges(function_defs) + end + + @impl true + def source_extensions, do: [".heex"] + + @impl true + def source_language(".heex"), do: :heex + def source_language(_), do: nil + + @impl true + def parse_file(path, opts), do: HEEx.parse_file(path, opts) + + @impl true + def lower_elixir_ast({:sigil_H, meta, [{:<<>>, _, [source]}, modifiers]}, opts) + when is_binary(source) and modifiers in [[], ~c"noformat"] do + HEEx.lower_sigil(source, meta, opts) + end + + def lower_elixir_ast(_ast, _opts), do: :ignore + + @impl true + def classify_effect(%Node{type: :call, meta: %{kind: :local, function: fun}}) + when fun in @pure_local, + do: :pure + + def classify_effect(%Node{type: :call, meta: %{kind: :remote, module: mod}}) + when mod in @pure_remote_modules, + do: :pure + + def classify_effect(_), do: nil + + @impl true + def behaviour_label(callbacks) do + if :mount in callbacks and :render in callbacks, do: "LiveView" + end + + @impl true + def ignore_call_edge?(%Graph.Edge{v2: {Phoenix.LiveView.TagEngine, fun, _arity}}) + when fun in [:component, :inner_block], + do: true + + def ignore_call_edge?(_edge), do: false + + defp live_event_edges(function_defs) do + handlers = event_handlers(function_defs) + + for func <- function_defs, + event_node <- func |> IR.all_nodes() |> Enum.filter(&live_event_node?/1), + event = event_name(event_node), + is_binary(event), + handler <- Map.get(handlers, {func.meta[:module], event}, []) do + {event_node.id, handler.id, {:live_event, event}} + end + end + + defp event_handlers(function_defs) do + function_defs + |> Enum.filter(&(&1.meta[:name] == :handle_event and &1.meta[:arity] == 3)) + |> Enum.flat_map(fn func -> + func.children + |> Enum.filter(&(&1.type == :clause)) + |> Enum.flat_map(&event_handler_clause(func, &1)) + end) + |> Enum.group_by(&elem(&1, 0), &elem(&1, 1)) + end + + defp event_handler_clause(func, clause) do + case clause.children do + [%Node{type: :literal, meta: %{value: event}} | _] when is_binary(event) -> + target = if clause.source_span, do: clause, else: func + [{{func.meta[:module], event}, target}] + + _ -> + [] + end + end + + defp live_event_node?(%Node{type: :call, meta: %{function: fun}}) when fun in @event_attrs, + do: true + + defp live_event_node?(%Node{type: :call, meta: %{function: fun}}) when fun in @event_functions, + do: true + + defp live_event_node?(%Node{ + type: :call, + meta: %{module: Phoenix.LiveView.JS, function: :push} + }), + do: true + + defp live_event_node?(%Node{type: :call, meta: %{module: JS, function: :push}}), do: true + defp live_event_node?(_), do: false + + defp event_name(%Node{ + meta: %{function: :__live_event__}, + children: [%Node{type: :literal, meta: %{value: event}} | _] + }), + do: event + + defp event_name(%Node{children: [%Node{type: :literal, meta: %{value: event}} | _]}) + when is_binary(event), + do: event + + defp event_name(_), do: nil + + defp live_assign_edges(function_defs) do + live_key_edges(function_defs, &assign_writes/1, &assign_reads/1, :live_assign) + end + + defp assign_writes( + %Node{type: :call, meta: %{function: fun, module: module}, children: children} = node + ) + when fun in [:assign, :assign_new, :assign_async] and module in @assign_modules do + case children do + [_socket, %Node{type: :literal, meta: %{value: key}} | _] when is_atom(key) -> + [{key, node}] + + [_socket, %Node{type: :map, children: fields} | _] -> + fields + |> Enum.flat_map(&map_field_key/1) + |> Enum.map(&{&1, node}) + + _ -> + [] + end + end + + defp assign_writes(_), do: [] + + defp assign_reads( + %Node{ + type: :call, + meta: %{function: :@}, + children: [%Node{type: :var, meta: %{name: key}}] + } = node + ) + when is_atom(key) and key not in [:streams, :uploads, :flash], + do: [{key, node}] + + defp assign_reads(_), do: [] + + defp live_component_attr_edges(function_defs) do + for func <- function_defs, + call <- IR.all_nodes(func), + component_call?(call), + attr <- component_attrs(call), + var <- attr_vars(attr) do + {var.id, call.id, {:live_component_attr, elem(attr, 0)}} + end + end + + # LiveView 1.1 fallback emits runtime component helper calls with generated + # assigns maps and slot internals. Those are too noisy for attr-flow edges. + # Parser-backed lowering emits direct component calls, which are safe to use. + defp component_call?(%Node{type: :call, meta: %{module: Phoenix.LiveView.TagEngine}}), do: false + + defp component_call?(%Node{type: :call, meta: %{origin: %{kind: :component}}}), do: true + defp component_call?(_), do: false + + defp component_attrs(%Node{children: children}) do + children + |> Enum.find(&(&1.type == :map)) + |> case do + %Node{children: fields} -> + Enum.flat_map(fields, &component_attr_field/1) + + _ -> + [] + end + end + + defp component_attrs(_), do: [] + + defp component_attr_field(%Node{type: :map_field, children: [key_node, value_node]}) do + case literal_key(key_node) do + nil -> [] + key when key in [:inner_block, :__changed__, :__slot__] -> [] + key -> [{key, value_node}] + end + end + + defp component_attr_field(_field), do: [] + + defp attr_vars({_key, value}) do + value + |> IR.all_nodes() + |> Enum.filter(fn + %Node{type: :var, source_span: %{}, meta: %{name: name}} -> + name not in [:_, :__MODULE__, :__ENV__, :assigns] + + _ -> + false + end) + end + + defp live_stream_edges(function_defs) do + live_key_edges(function_defs, &stream_writes/1, &stream_reads/1, :live_stream) + end + + defp live_key_edges(function_defs, write_fun, read_fun, edge_kind) do + by_module = + Enum.group_by(function_defs, & &1.meta[:module], fn func -> + nodes = IR.all_nodes(func) + + %{ + writes: Enum.flat_map(nodes, write_fun), + reads: Enum.flat_map(nodes, read_fun) + } + end) + + for {_module, groups} <- by_module, + write <- Enum.flat_map(groups, & &1.writes), + read <- Enum.flat_map(groups, & &1.reads), + elem(write, 0) == elem(read, 0) do + {_key, write_node} = write + {_key, read_node} = read + {write_node.id, read_node.id, {edge_kind, elem(write, 0)}} + end + end + + defp stream_writes( + %Node{ + type: :call, + meta: %{function: fun}, + children: [_socket, %Node{type: :literal, meta: %{value: key}} | _] + } = node + ) + when fun in @stream_functions and is_atom(key), + do: [{key, node}] + + defp stream_writes(_), do: [] + + defp stream_reads( + %Node{type: :call, meta: %{kind: :field_access, function: key}, children: children} = + node + ) + when is_atom(key) do + if Enum.any?(children, &streams_assign?/1), do: [{key, node}], else: [] + end + + defp stream_reads(_), do: [] + + defp streams_assign?(%Node{ + type: :call, + meta: %{function: :@}, + children: [%Node{type: :var, meta: %{name: :streams}}] + }), + do: true + + defp streams_assign?(_), do: false + + defp map_field_key(%Node{type: :map_field, children: [key_node | _]}) do + case literal_key(key_node) do + nil -> [] + key -> [key] + end + end + + defp map_field_key(_), do: [] + + defp literal_key(%Node{type: :literal, meta: %{value: key}}) + when is_atom(key) or is_binary(key), + do: key + + defp literal_key(_), do: nil +end diff --git a/lib/reach/plugins/live_view/heex.ex b/lib/reach/plugins/live_view/heex.ex new file mode 100644 index 0000000..8ffa25f --- /dev/null +++ b/lib/reach/plugins/live_view/heex.ex @@ -0,0 +1,245 @@ +defmodule Reach.Plugins.LiveView.HEEx do + @moduledoc false + + alias Reach.Frontend.Elixir, as: ElixirFrontend + alias Reach.IR.Counter + alias Reach.Plugins.LiveView.HEEx.{Lowerer, Parser} + alias Reach.Source.{Origin, Span} + + @tag_engine Phoenix.LiveView.TagEngine + @html_engine Phoenix.LiveView.HTMLEngine + @engine Reach.Plugins.LiveView.HEExEngine + + def lower_sigil(source, meta, opts) when is_binary(source) and is_list(meta) do + with :ok <- ensure_available(), + line = (meta[:line] || 1) + 1, + file = opts[:file], + {:ok, ast} <- lower_template(source, sigil_options(source, meta, opts), file, line) do + {:ok, put_origin(ast, :sigil, "~H", Span.from_meta(meta, file))} + else + :error -> {:error, :live_view_not_available} + {:error, _} = error -> error + end + end + + def parse_file(path, opts) do + with :ok <- ensure_available(), + {:ok, source} <- File.read(path), + {:ok, ast} <- lower_template(source, file_options(source, path, opts), path, 1) do + module = Keyword.get(opts, :module) || module_from_path(path) + wrapper = wrap_render(module, ast, path) + counter = Keyword.get_lazy(opts, :counter, &Counter.new/0) + {:ok, ElixirFrontend.translate_ast(wrapper, counter, path)} + else + {:error, _} = error -> error + :error -> {:error, :live_view_not_available} + end + end + + defp ensure_available do + if Code.ensure_loaded?(@tag_engine) and Code.ensure_loaded?(@html_engine) do + :ok + else + :error + end + end + + defp lower_template(source, opts, file, first_line) do + parser_opts = Keyword.merge(opts, file: file, line: first_line) + + case Parser.parse(source, parser_opts) do + {:ok, template} -> + {:ok, Lowerer.to_ast(template)} + + {:error, :live_view_parser_not_available} -> + compile_with_live_view(source, opts, file, first_line) + + {:error, _reason} = error -> + error + end + end + + defp compile_with_live_view(source, opts, file, first_line) do + opts = opts |> Keyword.put(:engine, @tag_engine) |> Keyword.put(:subengine, @engine) + + with {:ok, ast} <- compile(source, opts) do + {:ok, annotate_template_ast(ast, source, file, first_line)} + end + end + + defp compile(source, opts) do + {:ok, EEx.compile_string(source, opts)} + rescue + exception -> {:error, exception} + catch + kind, reason -> {:error, {kind, reason}} + end + + defp sigil_options(source, meta, opts) do + file = Keyword.get(opts, :file, "nofile") + + [ + engine: @engine, + file: file, + line: (meta[:line] || 1) + 1, + caller: caller(file, opts[:module]), + indentation: meta[:indentation] || 0, + source: source, + tag_handler: @html_engine + ] + end + + defp file_options(source, path, opts) do + [ + engine: @engine, + file: path, + line: 1, + caller: caller(path, opts[:module]), + indentation: 0, + source: source, + tag_handler: @html_engine + ] + end + + defp caller(file, module) do + env = + if function_exported?(Code, :env_for_eval, 1) do + Code.env_for_eval(file: file) + else + %{__ENV__ | file: file} + end + + %{env | module: module} + end + + defp annotate_template_ast(ast, source, file, first_line) do + lines = String.split(source, "\n", trim: false) + + Macro.postwalk(ast, fn + {form, meta, args} = node when is_list(meta) and is_list(args) -> + case template_origin(form, meta, args, lines, file, first_line) do + nil -> node + origin -> put_origin(node, origin) + end + + other -> + other + end) + end + + defp template_origin(form, meta, _args, lines, file, first_line) + when form in [:if, :case, :cond, :for, :with] do + origin_from_meta(meta, form, lines, file, first_line) + end + + defp template_origin(:<-, meta, _args, lines, file, first_line) do + origin_from_meta(meta, :for, lines, file, first_line) + end + + defp template_origin( + {:., _dot_meta, [{:__aliases__, _, [:Phoenix, :LiveView, :TagEngine]}, fun]}, + meta, + _args, + lines, + file, + first_line + ) + when fun in [:component, :inner_block] do + origin_from_meta(meta, fun, lines, file, first_line) + end + + defp template_origin(_form, _meta, _args, _lines, _file, _first_line), do: nil + + defp origin_from_meta(meta, kind, lines, file, first_line) do + case meta[:line] do + line when is_integer(line) -> + %Origin{ + language: :heex, + kind: kind, + label: line_label(lines, line, first_line, kind), + span: %Span{file: file, start_line: line, start_col: meta[:column] || 1}, + plugin: Reach.Plugins.LiveView, + generated?: true + } + + _ -> + nil + end + end + + defp line_label(lines, line, first_line, fallback) do + lines + |> Enum.at(line - first_line) + |> case do + nil -> to_string(fallback) + text -> text |> String.trim() |> blank_fallback(fallback) |> String.slice(0, 100) + end + end + + defp blank_fallback("", fallback), do: to_string(fallback) + defp blank_fallback(text, _fallback), do: text + + defp put_origin({form, meta, args} = ast, %Origin{} = origin) + when is_list(meta) and is_list(args) do + if Keyword.has_key?(meta, Reach.Source.metadata_key()) do + ast + else + {form, Keyword.put(meta, Reach.Source.metadata_key(), origin), args} + end + end + + defp put_origin(ast, %Origin{}), do: ast + + defp put_origin(ast, kind, label, span) do + put_origin(ast, %Origin{ + language: :heex, + kind: kind, + label: label, + span: span, + plugin: Reach.Plugins.LiveView, + generated?: true + }) + end + + defp wrap_render(module, ast, path) do + quote generated: true, line: 1 do + defmodule unquote(module) do + def render(assigns) do + unquote( + put_origin(ast, :file, Path.basename(path), %Span{ + file: path, + start_line: 1, + start_col: 1 + }) + ) + end + end + end + end + + defp module_from_path(path) do + path + |> Path.relative_to_cwd() + |> strip_extensions() + |> Path.split() + |> Enum.reject(&(&1 in [".", "/", ""])) + |> Enum.map(&module_segment/1) + |> then(fn + [] -> ["Template"] + segments -> segments + end) + |> then(&Module.concat([Reach.Templates | &1])) + end + + defp strip_extensions(path) do + path + |> Path.rootname() + |> Path.rootname() + end + + defp module_segment(segment) do + segment + |> String.replace(~r/[^A-Za-z0-9_]/, "_") + |> Macro.camelize() + end +end diff --git a/lib/reach/plugins/live_view/heex/lowerer.ex b/lib/reach/plugins/live_view/heex/lowerer.ex new file mode 100644 index 0000000..587c216 --- /dev/null +++ b/lib/reach/plugins/live_view/heex/lowerer.ex @@ -0,0 +1,289 @@ +defmodule Reach.Plugins.LiveView.HEEx.Lowerer do + @moduledoc false + + alias Reach.Plugins.LiveView.HEEx.Node + alias Reach.Source.Origin + + def to_ast(%Node.Template{children: children, span: span}) do + children + |> lower_children() + |> block_ast(origin(:template, "HEEx template", span)) + end + + def to_ast(%Node.Text{text: text, span: span}) do + static_ast(text, span) + end + + def to_ast(%Node.Expr{ast: ast, code: code, span: span}) do + put_origin(ast, origin(:expr, label_expr(code, "{}"), span)) + end + + def to_ast(%Node.EExBlock{} = block), do: lower_eex_block(block) + + def to_ast(%Node.Tag{} = tag), do: lower_tag(tag) + + defp lower_children(children) do + children + |> Enum.map(&to_ast/1) + |> Enum.reject(&is_nil/1) + end + + defp lower_eex_block(%Node.EExBlock{ + head_ast: {:if, meta, [condition, _]}, + clauses: clauses, + head_code: code, + span: span + }) do + {do_ast, else_ast} = if_clauses(clauses) + + {:if, put_origin_meta(meta, origin(:if, label_eex(code), span)), + [condition, [do: do_ast, else: else_ast]]} + end + + defp lower_eex_block(%Node.EExBlock{ + head_ast: {:case, meta, [expr, _]}, + clauses: clauses, + head_code: code, + span: span + }) do + clause_asts = Enum.map(clauses, &case_clause_ast/1) |> Enum.reject(&is_nil/1) + + {:case, put_origin_meta(meta, origin(:case, label_eex(code), span)), + [expr, [do: clause_asts]]} + end + + defp lower_eex_block(%Node.EExBlock{clauses: clauses, head_code: code, span: span}) do + clauses + |> Enum.flat_map(& &1.children) + |> lower_children() + |> block_ast(origin(:eex_block, label_eex(code), span)) + end + + defp if_clauses([ + %Node.EExClause{children: do_children}, + %Node.EExClause{children: else_children} | _ + ]) do + {block_ast(lower_children(do_children), nil), block_ast(lower_children(else_children), nil)} + end + + defp if_clauses([%Node.EExClause{children: do_children} | _]) do + {block_ast(lower_children(do_children), nil), nil} + end + + defp if_clauses(_), do: {nil, nil} + + defp case_clause_ast(%Node.EExClause{code: "end"}), do: nil + + defp case_clause_ast(%Node.EExClause{code: code, children: children, span: span}) do + [pattern_code | _] = String.split(code, "->", parts: 2) + + patterns = + pattern_code + |> String.trim() + |> parse_patterns(span) + + {:->, meta_from_span(span), [patterns, block_ast(lower_children(children), nil)]} + end + + defp lower_tag(%Node.Tag{} = tag) do + ast = lower_tag_body(tag) + + tag.special + |> Enum.reverse() + |> Enum.reduce(ast, fn + %Node.SpecialAttr{name: :if, ast: condition, span: span, code: code}, inner -> + {:if, meta_from_span(span, origin(:if, ":if #{code}", span)), [condition, [do: inner]]} + + %Node.SpecialAttr{name: :for, ast: {:<-, meta, args}, span: span, code: code}, inner -> + {:for, put_origin_meta(meta, origin(:for, ":for #{code}", span)), + [{:<-, put_origin_meta(meta, origin(:for, ":for #{code}", span)), args}, [do: inner]]} + + %Node.SpecialAttr{name: :for, ast: for_ast, span: span, code: code}, inner -> + {:for, meta_from_span(span, origin(:for, ":for #{code}", span)), [for_ast, [do: inner]]} + + _special, inner -> + inner + end) + end + + defp lower_tag_body(%Node.Tag{ + type: type, + name: name, + attrs: attrs, + children: children, + open_span: span + }) + when type in [:local_component, :remote_component] do + args = [assigns_map(attrs, children)] + call = component_call(type, name, args, span) + put_origin(call, origin(:component, component_label(type, name), span)) + end + + defp lower_tag_body(%Node.Tag{type: :slot, name: name, children: children, open_span: span}) do + block_ast(lower_children(children), origin(:slot, "<:#{name}>", span)) + end + + defp lower_tag_body(%Node.Tag{name: name, attrs: attrs, children: children, open_span: span}) do + event_attrs = event_attr_asts(attrs) + dynamic_attrs = dynamic_attr_asts(attrs) + body = lower_children(children) + parts = event_attrs ++ dynamic_attrs ++ body + + case parts do + [] -> static_ast("<#{name}>", span) + _ -> block_ast(parts, origin(:tag, "<#{name}>", span)) + end + end + + defp assigns_map(attrs, children) do + fields = + attrs + |> Enum.reject(&match?(%Node.SpecialAttr{}, &1)) + |> Enum.flat_map(&attr_field/1) + + inner = lower_children(children) + fields = if inner == [], do: fields, else: [{:inner_block, block_ast(inner, nil)} | fields] + {:%{}, [], fields} + end + + defp attr_field(%Node.Attr{name: name, value: {:expr, _code, ast}}), + do: [{source_atom(name), ast}] + + defp attr_field(%Node.Attr{name: name, value: {:string, value}}), + do: [{source_atom(name), value}] + + defp attr_field(_), do: [] + + @event_attrs ~w(phx-click phx-submit phx-change phx-keyup phx-keydown phx-blur phx-focus phx-window-keyup phx-window-keydown) + + defp event_attr_asts(attrs) do + attrs + |> Enum.flat_map(fn + %Node.Attr{name: name, value: {:string, event}, span: span} when name in @event_attrs -> + [event_call(name, event, span)] + + %Node.Attr{name: name, value: {:expr, _code, ast}, span: span} when name in @event_attrs -> + [put_origin(ast, origin(:event, name, span))] + + _ -> + [] + end) + end + + defp event_call(name, event, span) do + meta = meta_from_span(span, origin(:event, "#{name}=#{inspect(event)}", span)) + {:__live_event__, meta, [event]} + end + + defp dynamic_attr_asts(attrs) do + attrs + |> Enum.reject(fn + %Node.SpecialAttr{} -> true + %Node.Attr{name: name} -> name in @event_attrs + _ -> false + end) + |> Enum.flat_map(fn + %Node.Attr{value: {:expr, _code, ast}, span: span, name: name} -> + [put_origin(ast, origin(:attr, name, span))] + + _ -> + [] + end) + end + + defp component_label(:local_component, name), do: "<.#{name}>" + defp component_label(:remote_component, name), do: "<#{name}>" + + defp component_call(:local_component, name, args, span) do + {source_atom(name), meta_from_span(span), args} + end + + defp component_call(:remote_component, name, args, span) do + case remote_component_parts(name) do + {mod, fun} -> {{:., meta_from_span(span), [mod, fun]}, meta_from_span(span), args} + nil -> {source_atom(name), meta_from_span(span), args} + end + end + + defp remote_component_parts(name) do + case String.split(name, ".") do + [_] -> + nil + + parts -> + {fun, mod_parts} = List.pop_at(parts, -1) + {{:__aliases__, [], Enum.map(mod_parts, &source_atom/1)}, source_atom(fun)} + end + end + + defp source_atom(name) when is_binary(name), do: :erlang.binary_to_atom(name, :utf8) + + defp block_ast([], _origin), do: nil + defp block_ast([single], nil), do: single + defp block_ast([single], %Origin{} = origin), do: put_origin(single, origin) + defp block_ast(parts, nil), do: {:__block__, [], parts} + defp block_ast(parts, %Origin{} = origin), do: {:__block__, [reach: origin], parts} + + defp static_ast(text, span) do + label = text |> to_string() |> String.trim() |> String.slice(0, 100) + + {:__block__, [reach: origin(:static, if(label == "", do: "static HEEx", else: label), span)], + [:heex_static]} + end + + defp put_origin({form, meta, args}, %Origin{} = origin) when is_list(meta) and is_list(args) do + {form, put_origin_meta(meta, origin), args} + end + + defp put_origin(other, _origin), do: other + + defp put_origin_meta(meta, %Origin{} = origin), + do: Keyword.put_new(meta || [], Reach.Source.metadata_key(), origin) + + defp origin(kind, label, span) do + %Origin{ + language: :heex, + kind: kind, + label: label, + span: span, + plugin: Reach.Plugins.LiveView, + generated?: true + } + end + + defp meta_from_span(span, origin \\ nil) + + defp meta_from_span(%{start_line: line, start_col: col}, nil), + do: [line: line, column: col || 1] + + defp meta_from_span(%{start_line: line, start_col: col}, %Origin{} = origin), + do: [line: line, column: col || 1, reach: origin] + + defp meta_from_span(_, nil), do: [] + defp meta_from_span(_, %Origin{} = origin), do: [reach: origin] + + defp parse_patterns(code, span) do + code = String.trim(code) + + case Code.string_to_quoted("[" <> code <> "]", + line: span_line(span), + column: span_col(span), + columns: true + ) do + {:ok, list} -> list_values(list) + _ -> [Macro.var(:_, nil)] + end + end + + defp list_values({:__block__, _, [list]}), do: list_values(list) + defp list_values(list) when is_list(list), do: list + defp list_values(other), do: [other] + + defp span_line(%{start_line: line}) when is_integer(line), do: line + defp span_line(_), do: 1 + defp span_col(%{start_col: col}) when is_integer(col), do: col + defp span_col(_), do: 1 + + defp label_eex(code), do: "<%= #{String.trim(code)} %>" + defp label_expr(code, wrapper), do: String.replace(wrapper, "{}", "{#{String.trim(code)}}") +end diff --git a/lib/reach/plugins/live_view/heex/node.ex b/lib/reach/plugins/live_view/heex/node.ex new file mode 100644 index 0000000..343a618 --- /dev/null +++ b/lib/reach/plugins/live_view/heex/node.ex @@ -0,0 +1,43 @@ +defmodule Reach.Plugins.LiveView.HEEx.Node do + @moduledoc false + + defmodule Template do + @moduledoc false + defstruct [:children, :span] + end + + defmodule Text do + @moduledoc false + defstruct [:text, :span] + end + + defmodule Expr do + @moduledoc false + defstruct [:marker, :code, :ast, :span] + end + + defmodule EExBlock do + @moduledoc false + defstruct [:marker, :head_code, :head_ast, :clauses, :span] + end + + defmodule EExClause do + @moduledoc false + defstruct [:code, :ast, :children, :span] + end + + defmodule Tag do + @moduledoc false + defstruct [:type, :name, :attrs, :special, :children, :open_span, :close_span, :span] + end + + defmodule Attr do + @moduledoc false + defstruct [:name, :value, :span] + end + + defmodule SpecialAttr do + @moduledoc false + defstruct [:name, :code, :ast, :span] + end +end diff --git a/lib/reach/plugins/live_view/heex/parser.ex b/lib/reach/plugins/live_view/heex/parser.ex new file mode 100644 index 0000000..9368815 --- /dev/null +++ b/lib/reach/plugins/live_view/heex/parser.ex @@ -0,0 +1,230 @@ +defmodule Reach.Plugins.LiveView.HEEx.Parser do + @moduledoc false + + alias Reach.Plugins.LiveView.HEEx.Node + alias Reach.Source.Span + + @parser Phoenix.LiveView.TagEngine.Parser + @html_engine Phoenix.LiveView.HTMLEngine + + def parse(source, opts) when is_binary(source) do + with :ok <- ensure_parser(), + {:ok, parsed} <- parse_with_live_view(source, opts) do + previous_file = Process.get(:reach_heex_file) + Process.put(:reach_heex_file, Keyword.get(opts, :file)) + + try do + {:ok, + %Node.Template{ + children: normalize_nodes(parsed.nodes), + span: template_span(source, opts) + }} + after + restore_process_value(:reach_heex_file, previous_file) + end + end + end + + defp ensure_parser do + if Code.ensure_loaded?(@parser) and function_exported?(@parser, :parse, 2) do + :ok + else + {:error, :live_view_parser_not_available} + end + end + + defp parse_with_live_view(source, opts) do + parser_opts = [ + file: Keyword.get(opts, :file, "nofile"), + line: Keyword.get(opts, :line, 1), + caller: Keyword.get(opts, :caller), + indentation: Keyword.get(opts, :indentation, 0), + tag_handler: @html_engine, + skip_macro_components: true + ] + + case :erlang.apply(@parser, :parse, [source, parser_opts]) do + {:ok, parsed} -> {:ok, parsed} + {:error, line, column, message} -> {:error, {line, column, message}} + other -> {:error, other} + end + rescue + exception -> {:error, exception} + catch + kind, reason -> {:error, {kind, reason}} + end + + defp normalize_nodes(nodes), do: Enum.map(nodes, &normalize_node/1) + + defp normalize_node({:text, text, meta}) do + %Node.Text{text: text, span: span(meta)} + end + + defp normalize_node({:body_expr, code, meta}) do + %Node.Expr{marker: "=", code: code, ast: parse_expr(code, meta), span: span(meta)} + end + + defp normalize_node({:eex, code, meta}) do + %Node.Expr{ + marker: meta |> Map.get(:opt, "") |> to_string(), + code: code, + ast: parse_expr(code, meta), + span: span(meta) + } + end + + defp normalize_node({:eex_comment, text}), do: %Node.Text{text: text, span: nil} + + defp normalize_node({:eex_block, code, clauses, meta}) do + %Node.EExBlock{ + marker: meta |> Map.get(:opt, "") |> to_string(), + head_code: code, + head_ast: parse_block_head(code, meta), + clauses: Enum.map(clauses, &normalize_clause/1), + span: span(meta) + } + end + + defp normalize_node({:block, type, name, attrs, children, open_meta, close_meta}) do + attrs = normalize_attrs(attrs) + + %Node.Tag{ + type: type, + name: name, + attrs: attrs, + special: special_attrs(attrs), + children: normalize_nodes(children), + open_span: span(open_meta), + close_span: span(close_meta), + span: merge_spans(span(open_meta), span(close_meta)) + } + end + + defp normalize_node({:self_close, type, name, attrs, meta}) do + attrs = normalize_attrs(attrs) + + %Node.Tag{ + type: type, + name: name, + attrs: attrs, + special: special_attrs(attrs), + children: [], + open_span: span(meta), + close_span: span(meta), + span: span(meta) + } + end + + defp normalize_node(other), do: %Node.Text{text: inspect(other), span: nil} + + defp normalize_clause({children, code, meta}) do + %Node.EExClause{ + code: code, + ast: parse_clause(code, meta), + children: normalize_nodes(children), + span: span(meta) + } + end + + defp normalize_attrs(attrs), do: Enum.map(attrs, &normalize_attr/1) + + defp normalize_attr({name, {:expr, code, expr_meta}, meta}) + when name in [":if", ":for", ":key"] do + %Node.SpecialAttr{ + name: name |> String.trim_leading(":") |> source_atom(), + code: code, + ast: parse_expr(code, expr_meta), + span: span(meta) + } + end + + defp normalize_attr({name, {:expr, code, expr_meta}, meta}) do + %Node.Attr{name: name, value: {:expr, code, parse_expr(code, expr_meta)}, span: span(meta)} + end + + defp normalize_attr({name, {:string, value, _value_meta}, meta}) do + %Node.Attr{name: name, value: {:string, value}, span: span(meta)} + end + + defp normalize_attr({name, value, meta}) do + %Node.Attr{name: name, value: value, span: span(meta)} + end + + defp special_attrs(attrs), do: Enum.filter(attrs, &match?(%Node.SpecialAttr{}, &1)) + + defp source_atom(name) when is_binary(name), do: :erlang.binary_to_atom(name, :utf8) + + defp parse_expr(code, meta) do + Code.string_to_quoted!(code, line: meta_line(meta), column: meta_column(meta), columns: true) + rescue + _ -> code + end + + defp parse_block_head(code, meta) do + trimmed = String.trim(code) + + cond do + String.starts_with?(trimmed, "if ") or String.starts_with?(trimmed, "unless ") -> + parse_expr(trimmed <> "\n nil\nend", meta) + + String.starts_with?(trimmed, "case ") and String.ends_with?(trimmed, " do") -> + expr = trimmed |> String.trim_leading("case ") |> String.trim_trailing(" do") + {:case, meta_list(meta), [parse_expr(expr, meta), [do: []]]} + + String.starts_with?(trimmed, "cond do") -> + {:cond, meta_list(meta), [[do: []]]} + + true -> + parse_expr(trimmed, meta) + end + end + + defp parse_clause("end", _meta), do: :end + + defp parse_clause(code, meta), + do: parse_expr("case :__reach__ do\n" <> code <> " :__reach_clause__\nend", meta) + + defp span(meta) when is_map(meta) do + %Span{ + file: Map.get(meta, :file) || Process.get(:reach_heex_file), + start_line: Map.get(meta, :line), + start_col: Map.get(meta, :column), + end_line: Map.get(meta, :line_end), + end_col: Map.get(meta, :column_end) + } + end + + defp span(_), do: nil + + defp template_span(source, opts) do + first_line = Keyword.get(opts, :line, 1) + line_count = max(length(String.split(source, "\n", trim: false)) - 1, 0) + + %Span{ + file: Keyword.get(opts, :file), + start_line: first_line, + start_col: 1, + end_line: first_line + line_count, + end_col: nil + } + end + + defp merge_spans(%Span{} = first, %Span{} = last), + do: %{ + first + | end_line: last.end_line || last.start_line, + end_col: last.end_col || last.start_col + } + + defp merge_spans(first, _), do: first + + defp meta_list(meta), do: [line: meta_line(meta), column: meta_column(meta)] + + defp restore_process_value(key, nil), do: Process.delete(key) + defp restore_process_value(key, value), do: Process.put(key, value) + + defp meta_line(meta) when is_map(meta), do: Map.get(meta, :line, 1) + defp meta_line(_), do: 1 + defp meta_column(meta) when is_map(meta), do: Map.get(meta, :column, 1) + defp meta_column(_), do: 1 +end diff --git a/lib/reach/plugins/live_view/heex_engine.ex b/lib/reach/plugins/live_view/heex_engine.ex new file mode 100644 index 0000000..f48d36f --- /dev/null +++ b/lib/reach/plugins/live_view/heex_engine.ex @@ -0,0 +1,59 @@ +defmodule Reach.Plugins.LiveView.HEExEngine do + @moduledoc false + + @behaviour EEx.Engine + + @impl true + def init(opts) do + %{parts: [], caller: opts[:caller]} + end + + @impl true + def handle_begin(_state) do + %{parts: []} + end + + @impl true + def handle_end(state) do + parts = Enum.reverse(state.parts) + + case parts do + [] -> :ok + [single] -> single + _ -> {:__block__, [], parts} + end + end + + def handle_end(state, _opts), do: handle_end(state) + + @impl true + def handle_body(state) do + handle_end(state) + end + + def handle_body(state, _opts) do + handle_end(state) + end + + @impl true + def handle_text(state, meta, text) do + if String.trim(to_string(text)) == "" do + state + else + append(state, text_ast(text, meta)) + end + end + + @impl true + def handle_expr(state, _marker, ast) do + append(state, ast) + end + + defp append(state, ast), do: %{state | parts: [ast | state.parts]} + + defp text_ast(text, meta) when is_list(meta) do + {:__block__, meta, [to_string(text)]} + end + + defp text_ast(text, _meta), do: to_string(text) +end diff --git a/lib/reach/project.ex b/lib/reach/project.ex index c842555..87c18cb 100644 --- a/lib/reach/project.ex +++ b/lib/reach/project.ex @@ -20,7 +20,7 @@ defmodule Reach.Project do ) """ - alias Reach.{DependencySummary, Frontend, IR} + alias Reach.{DependencySummary, Frontend, IR, Plugin} alias Reach.IR.Counter import Reach.IR.Helpers, only: [module_from_path: 1] @@ -69,8 +69,10 @@ defmodule Reach.Project do """ @spec from_mix_project(keyword()) :: t() def from_mix_project(opts \\ []) do + plugins = Reach.Plugin.resolve(opts) + source_roots() - |> Enum.flat_map(&source_files/1) + |> Enum.flat_map(&source_files(&1, plugins)) |> Enum.uniq() |> Enum.sort() |> from_sources(opts) @@ -136,8 +138,9 @@ defmodule Reach.Project do _ -> [] end - defp source_files({elixirc_paths, erlc_paths}) do - elixir_files = glob_extensions(elixirc_paths, [".ex"]) + defp source_files({elixirc_paths, erlc_paths}, plugins) do + plugin_extensions = Reach.Plugin.source_extensions(plugins) + elixir_files = glob_extensions(elixirc_paths, [".ex"] ++ plugin_extensions) erlang_files = glob_extensions(erlc_paths, [".erl"]) elixir_files ++ erlang_files end @@ -241,7 +244,7 @@ defmodule Reach.Project do counter = Keyword.get_lazy(opts, :counter, &Counter.new/0) paths - |> Task.async_stream(&parse_path(&1, counter), + |> Task.async_stream(&parse_path(&1, counter, opts), max_concurrency: System.schedulers_online(), ordered: false ) @@ -251,10 +254,11 @@ defmodule Reach.Project do end) end - defp parse_path(path, counter) do + defp parse_path(path, counter, opts) do module_name = module_from_path(path) + parse_opts = Keyword.merge(opts, file: path, counter: counter) - case Frontend.parse_file(path, file: path, counter: counter) do + case parse_source_file(path, parse_opts) do {:ok, ir_nodes} -> {module_name || extract_module_name(ir_nodes), path, ir_nodes} @@ -263,6 +267,17 @@ defmodule Reach.Project do end end + defp parse_source_file(path, opts) do + plugins = Plugin.resolve(opts) + ext = Path.extname(path) + + if ext in Plugin.source_extensions(plugins) do + Plugin.parse_file(plugins, path, opts) + else + Frontend.parse_file(path, opts) + end + end + defp build_module_sdgs(parsed_modules, opts) do Reach.Effects.ensure_cache() summaries = Keyword.get(opts, :summaries, %{}) diff --git a/lib/reach/source.ex b/lib/reach/source.ex new file mode 100644 index 0000000..0782b93 --- /dev/null +++ b/lib/reach/source.ex @@ -0,0 +1,21 @@ +defmodule Reach.Source do + @moduledoc "Helpers for attaching source-origin metadata to lowered AST." + + alias Reach.Source.{Origin, Span} + + @metadata_key :reach + + def metadata_key, do: @metadata_key + + def origin(meta) when is_list(meta), do: Keyword.get(meta, @metadata_key) + def origin(_), do: nil + + def put_origin({form, meta, args}, %Origin{} = origin) when is_list(meta) and is_list(args) do + {form, Keyword.put(meta, @metadata_key, origin), args} + end + + def put_origin(ast, _origin), do: ast + + def span_from_origin(%Origin{span: span}), do: Span.to_map(span) + def span_from_origin(_), do: nil +end diff --git a/lib/reach/source/origin.ex b/lib/reach/source/origin.ex new file mode 100644 index 0000000..6ed3188 --- /dev/null +++ b/lib/reach/source/origin.ex @@ -0,0 +1,14 @@ +defmodule Reach.Source.Origin do + @moduledoc "Origin metadata for AST lowered from framework or template syntax." + + @type t :: %__MODULE__{ + language: atom(), + kind: atom(), + label: String.t() | nil, + span: Reach.Source.Span.t() | map() | nil, + plugin: module() | nil, + generated?: boolean() + } + + defstruct [:language, :kind, :label, :span, :plugin, generated?: false] +end diff --git a/lib/reach/source/span.ex b/lib/reach/source/span.ex new file mode 100644 index 0000000..346f9d4 --- /dev/null +++ b/lib/reach/source/span.ex @@ -0,0 +1,45 @@ +defmodule Reach.Source.Span do + @moduledoc "Source span metadata used by lowered plugin AST and Reach IR nodes." + + @type t :: %__MODULE__{ + file: String.t() | nil, + start_line: pos_integer() | nil, + start_col: pos_integer() | nil, + end_line: pos_integer() | nil, + end_col: pos_integer() | nil + } + + defstruct [:file, :start_line, :start_col, :end_line, :end_col] + + def from_meta(meta, file) when is_list(meta) do + case meta[:line] do + nil -> + nil + + line -> + %__MODULE__{ + file: file, + start_line: line, + start_col: meta[:column] || 1, + end_line: nil, + end_col: nil + } + end + end + + def from_meta(_, _), do: nil + + def to_map(nil), do: nil + + def to_map(%__MODULE__{} = span) do + %{ + file: span.file, + start_line: span.start_line, + start_col: span.start_col, + end_line: span.end_line, + end_col: span.end_col + } + end + + def to_map(%{} = span), do: span +end diff --git a/lib/reach/visualize/control_flow.ex b/lib/reach/visualize/control_flow.ex index 9cddce1..884b8da 100644 --- a/lib/reach/visualize/control_flow.ex +++ b/lib/reach/visualize/control_flow.ex @@ -561,6 +561,7 @@ defmodule Reach.Visualize.ControlFlow do |> Enum.map(& &1.v1) end + defp branch_label(%{meta: %{origin: %{label: label}}}) when is_binary(label), do: label defp branch_label(%{type: :case, meta: %{desugared_from: :if}}), do: "if" defp branch_label(%{type: :case, meta: %{desugared_from: :unless}}), do: "unless" defp branch_label(%{type: :case}), do: "case" diff --git a/lib/reach/visualize/helpers.ex b/lib/reach/visualize/helpers.ex index c2eb869..b132f9d 100644 --- a/lib/reach/visualize/helpers.ex +++ b/lib/reach/visualize/helpers.ex @@ -16,6 +16,7 @@ defmodule Reach.Visualize.Helpers do end end + def ir_label(%{meta: %{origin: %{label: label}}}) when is_binary(label), do: label def ir_label(%{type: :literal, meta: %{value: val}}), do: inspect(val) def ir_label(%{type: :var, meta: %{name: name}}), do: to_string(name) def ir_label(%{type: :tuple}), do: "{...}" diff --git a/test/reach/plugin/lower_elixir_ast_test.exs b/test/reach/plugin/lower_elixir_ast_test.exs new file mode 100644 index 0000000..7c84efb --- /dev/null +++ b/test/reach/plugin/lower_elixir_ast_test.exs @@ -0,0 +1,63 @@ +defmodule Reach.Plugin.LowerElixirASTTest do + use ExUnit.Case, async: true + + alias Reach.Frontend.Elixir, as: ElixirFrontend + alias Reach.Source.{Origin, Span} + + defmodule LoweringPlugin do + @behaviour Reach.Plugin + + @impl true + def analyze(_all_nodes, _opts), do: [] + + @impl true + def lower_elixir_ast({:dsl_if, meta, [condition, body]}, opts) do + span = %Span{file: opts[:file], start_line: meta[:line], start_col: meta[:column] || 1} + + ast = + {:if, + [ + line: meta[:line], + column: meta[:column], + reach: %Origin{ + language: :test_dsl, + kind: :if, + label: "dsl_if", + span: span, + plugin: __MODULE__, + generated?: true + } + ], [condition, [do: body]]} + + {:ok, ast} + end + + def lower_elixir_ast(_ast, _opts), do: :ignore + end + + test "plugins lower local Elixir AST before IR translation" do + source = """ + defmodule Demo do + def render(assigns) do + dsl_if(assigns.ok, :visible) + end + end + """ + + assert {:ok, nodes} = + ElixirFrontend.parse(source, file: "demo.ex", plugins: [LoweringPlugin]) + + lowered_case = + nodes + |> flatten() + |> Enum.find(&(&1.type == :case and &1.meta[:origin])) + + assert lowered_case.meta.origin.label == "dsl_if" + assert lowered_case.meta.origin.language == :test_dsl + assert lowered_case.source_span.file == "demo.ex" + assert lowered_case.source_span.start_line == 3 + end + + defp flatten(nodes) when is_list(nodes), do: Enum.flat_map(nodes, &flatten/1) + defp flatten(%{children: children} = node), do: [node | flatten(children)] +end diff --git a/test/reach/plugins/live_view/heex_lowerer_test.exs b/test/reach/plugins/live_view/heex_lowerer_test.exs new file mode 100644 index 0000000..ed2207a --- /dev/null +++ b/test/reach/plugins/live_view/heex_lowerer_test.exs @@ -0,0 +1,127 @@ +defmodule Reach.Plugins.LiveView.HEExLowererTest do + use ExUnit.Case, async: true + + alias Reach.Frontend.Elixir, as: ElixirFrontend + alias Reach.IR.Counter + alias Reach.Plugins.LiveView.HEEx.Lowerer + alias Reach.Plugins.LiveView.HEEx.Node + alias Reach.Source.Span + + test "lowers special :for and :if attributes to separate control nodes with HEEx origins" do + span = %Span{file: "demo.heex", start_line: 2, start_col: 1} + + tree = %Node.Template{ + children: [ + %Node.Tag{ + type: :tag, + name: "li", + open_span: span, + span: span, + attrs: [], + special: [ + %Node.SpecialAttr{ + name: :for, + code: "item <- @items", + ast: + {:<-, [line: 2, column: 6], + [ + {:item, [line: 2, column: 6], nil}, + {:@, [line: 2, column: 14], [{:items, [line: 2, column: 15], nil}]} + ]}, + span: span + }, + %Node.SpecialAttr{ + name: :if, + code: "item.visible?", + ast: + {{:., [line: 2, column: 28], [{:item, [line: 2, column: 28], nil}, :visible?]}, + [line: 2, column: 32], []}, + span: span + } + ], + children: [ + %Node.Expr{ + code: "item.name", + ast: + {{:., [line: 2, column: 45], [{:item, [line: 2, column: 45], nil}, :name]}, + [line: 2, column: 49], []}, + span: span + } + ] + } + ], + span: span + } + + ast = Lowerer.to_ast(tree) + nodes = ElixirFrontend.translate_ast(ast, Counter.new(), "demo.heex") |> flatten() + + assert Enum.any?(nodes, &(&1.type == :comprehension and &1.source_span.start_line == 2)) + assert Enum.any?(nodes, &(&1.type == :generator and &1.meta.origin.kind == :for)) + assert Enum.any?(nodes, &(&1.type == :case and &1.meta.origin.kind == :if)) + end + + test "lowers phx event attributes to synthetic event calls" do + span = %Span{file: "demo.heex", start_line: 1, start_col: 1} + + tree = %Node.Template{ + children: [ + %Node.Tag{ + type: :tag, + name: "button", + open_span: span, + span: span, + attrs: [%Node.Attr{name: "phx-click", value: {:string, "save"}, span: span}], + special: [], + children: [%Node.Text{text: "Save", span: span}] + } + ], + span: span + } + + ast = Lowerer.to_ast(tree) + nodes = ElixirFrontend.translate_ast(ast, Counter.new(), "demo.heex") |> flatten() + + assert Enum.any?(nodes, &(&1.type == :call and &1.meta[:function] == :__live_event__)) + end + + test "lowers local components to component calls instead of LiveView runtime helpers" do + span = %Span{file: "demo.heex", start_line: 1, start_col: 1} + + tree = %Node.Template{ + children: [ + %Node.Tag{ + type: :local_component, + name: "card", + open_span: span, + span: span, + attrs: [ + %Node.Attr{ + name: "title", + value: + {:expr, "@title", + {:@, [line: 1, column: 15], [{:title, [line: 1, column: 16], nil}]}}, + span: span + } + ], + special: [], + children: [] + } + ], + span: span + } + + ast = Lowerer.to_ast(tree) + nodes = ElixirFrontend.translate_ast(ast, Counter.new(), "demo.heex") |> flatten() + + assert Enum.any?(nodes, &(&1.type == :call and &1.meta[:function] == :card)) + + refute Enum.any?( + nodes, + &(&1.type == :call and &1.meta[:module] == Phoenix.LiveView.TagEngine) + ) + end + + defp flatten(nodes) when is_list(nodes), do: Enum.flat_map(nodes, &flatten/1) + defp flatten(%{children: children} = node), do: [node | flatten(children)] +end diff --git a/test/reach/plugins/live_view/semantics_test.exs b/test/reach/plugins/live_view/semantics_test.exs new file mode 100644 index 0000000..e2bb559 --- /dev/null +++ b/test/reach/plugins/live_view/semantics_test.exs @@ -0,0 +1,113 @@ +defmodule Reach.Plugins.LiveView.SemanticsTest do + use ExUnit.Case, async: true + + alias Reach.Frontend.Elixir, as: ElixirFrontend + alias Reach.IR + alias Reach.Plugins.LiveView + + test "connects template events to handle_event clauses" do + nodes = + parse!(""" + defmodule Demo do + def render(assigns), do: __live_event__("save") + def handle_event("save", _params, socket), do: {:noreply, socket} + end + """) + + assert Enum.any?( + LiveView.analyze(IR.all_nodes(nodes), []), + &match?({_, _, {:live_event, "save"}}, &1) + ) + end + + test "connects assign writes to template assign reads" do + nodes = + parse!(""" + defmodule Demo do + def mount(socket) do + assign(socket, :user, load_user()) + end + + def render(assigns), do: @user + end + """) + + assert Enum.any?( + LiveView.analyze(IR.all_nodes(nodes), []), + &match?({_, _, {:live_assign, :user}}, &1) + ) + end + + test "does not connect generated LiveView runtime component helper attrs" do + nodes = + parse!(""" + defmodule Demo do + def render(assigns) do + Phoenix.LiveView.TagEngine.component(&card/1, %{user: @user}, {__MODULE__, :render, __ENV__.file, 1}) + end + end + """) + + refute Enum.any?( + LiveView.analyze(IR.all_nodes(nodes), []), + &match?({_, _, {:live_component_attr, :user}}, &1) + ) + end + + test "connects component attr values to parser-lowered component calls" do + nodes = + parse!(""" + defmodule Demo do + def render(assigns), do: card(%{user: @user}) + end + """) + + component_call = + nodes |> IR.all_nodes() |> Enum.find(&(&1.type == :call and &1.meta[:function] == :card)) + + origin = %Reach.Source.Origin{ + language: :heex, + kind: :component, + label: "<.card>", + plugin: LiveView, + generated?: true + } + + component_call = %{component_call | meta: Map.put(component_call.meta, :origin, origin)} + + all_nodes = replace_node(nodes, component_call.id, component_call) |> IR.all_nodes() + + assert Enum.any?( + LiveView.analyze(all_nodes, []), + &match?({_, _, {:live_component_attr, :user}}, &1) + ) + end + + test "connects stream writes to @streams reads" do + nodes = + parse!(""" + defmodule Demo do + def mount(socket), do: stream(socket, :posts, []) + def render(assigns), do: @streams.posts + end + """) + + assert Enum.any?( + LiveView.analyze(IR.all_nodes(nodes), []), + &match?({_, _, {:live_stream, :posts}}, &1) + ) + end + + defp parse!(source) do + {:ok, nodes} = ElixirFrontend.parse(source, file: "demo.ex", plugins: []) + nodes + end + + defp replace_node(nodes, id, replacement) when is_list(nodes), + do: Enum.map(nodes, &replace_node(&1, id, replacement)) + + defp replace_node(%{id: id} = _node, id, replacement), do: replacement + + defp replace_node(%{children: children} = node, id, replacement), + do: %{node | children: replace_node(children, id, replacement)} +end diff --git a/test/reach/plugins/live_view_test.exs b/test/reach/plugins/live_view_test.exs new file mode 100644 index 0000000..b3698c3 --- /dev/null +++ b/test/reach/plugins/live_view_test.exs @@ -0,0 +1,38 @@ +defmodule Reach.Plugins.LiveViewTest do + use ExUnit.Case, async: true + + alias Reach.Plugins.LiveView + + test "owns HEEx sources" do + assert LiveView.source_extensions() == [".heex"] + assert LiveView.source_language(".heex") == :heex + end + + test "reports unavailable LiveView when lowering without phoenix_live_view loaded" do + ast = {:sigil_H, [line: 1, column: 1], [{:<<>>, [line: 1], ["

Hello

"]}, []]} + + unless Code.ensure_loaded?(Phoenix.LiveView.TagEngine) do + assert LiveView.lower_elixir_ast(ast, file: "demo.ex") == {:error, :live_view_not_available} + end + end + + test "hides LiveView rendering helper calls from call graph presentation" do + assert LiveView.ignore_call_edge?(%Graph.Edge{ + v1: {Demo, :render, 1}, + v2: {Phoenix.LiveView.TagEngine, :component, 3}, + label: nil + }) + + assert LiveView.ignore_call_edge?(%Graph.Edge{ + v1: {Demo, :render, 1}, + v2: {Phoenix.LiveView.TagEngine, :inner_block, 2}, + label: nil + }) + + refute LiveView.ignore_call_edge?(%Graph.Edge{ + v1: {Demo, :render, 1}, + v2: {Demo, :component, 1}, + label: nil + }) + end +end