@@ -33,7 +30,7 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do
<.collapsible
:if={@has_children?}
id={@id <> "collapsible"}
- open={@expanded?}
+ open={@node.open?}
icon="icon-chevron-right"
label_class="max-w-max"
chevron_class="text-code-2 m-auto w-[2ch] h-[2ch]"
@@ -50,11 +47,9 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do
<.text_items items={@node.expanded_after} />
@@ -64,6 +59,48 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do
"""
end
+ attr(:node, TermNode, required: true)
+
+ def static_term(assigns) do
+ assigns =
+ assigns
+ |> assign(:has_children?, TermNode.has_children?(assigns.node))
+
+ ~H"""
+
+ <%= if @has_children? do %>
+ <.static_collapsible
+ open={@node.open?}
+ label_class="max-w-max"
+ chevron_class="text-code-2 m-auto w-[2ch] h-[2ch]"
+ phx-click="toggle_node"
+ phx-value-id={@node.id}
+ >
+ <:label :let={open}>
+ <%= if open do %>
+ <.text_items items={@node.expanded_before} />
+ <% else %>
+ <.text_items items={@node.content} />
+ <% end %>
+
+
+ -
+ <.static_term node={child} />
+
+
+
+ <.text_items items={@node.expanded_after} />
+
+
+ <% else %>
+
+ <.text_items items={@node.content} />
+
+ <% end %>
+
+ """
+ end
+
attr(:items, :list, required: true)
defp text_items(assigns) do
@@ -81,16 +118,4 @@ defmodule LiveDebugger.App.Debugger.Web.Components.ElixirDisplay do
defp text_item_color_class(%DisplayElement{color: color}) do
if color, do: "#{color}", else: ""
end
-
- defp auto_expand?(%TermNode{}, 1), do: true
-
- defp auto_expand?(%TermNode{} = node, _level) do
- node.kind == :tuple and children_number(node) <= @max_auto_expand_size
- end
-
- defp has_children?(%TermNode{children: []}), do: false
- defp has_children?(%TermNode{}), do: true
-
- defp children_number(%TermNode{children: nil}), do: 0
- defp children_number(%TermNode{children: children}), do: length(children)
end
diff --git a/lib/live_debugger/app/utils/term_differ.ex b/lib/live_debugger/app/utils/term_differ.ex
index bbf4f449c..12cc213b8 100644
--- a/lib/live_debugger/app/utils/term_differ.ex
+++ b/lib/live_debugger/app/utils/term_differ.ex
@@ -54,7 +54,7 @@ defmodule LiveDebugger.App.Utils.TermDiffer do
Struct for representing a diff between two terms.
"""
- defstruct [:type, ins: %{}, del: %{}, diff: %{}]
+ defstruct type: :equal, ins: %{}, del: %{}, diff: %{}
@type type() :: :map | :list | :tuple | :struct | :primitive | :equal
@type key() :: any()
@@ -72,6 +72,14 @@ defmodule LiveDebugger.App.Utils.TermDiffer do
"""
def primitive_key, do: @primitive_key
+ @doc """
+ Returns the new value for `primitive` type.
+ """
+ @spec primitive_new_value(Diff.t()) :: term()
+ def primitive_new_value(%Diff{type: :primitive, ins: %{@primitive_key => new_value}}) do
+ new_value
+ end
+
@doc """
Calculates the diff between two terms.
"""
diff --git a/lib/live_debugger/app/utils/term_node.ex b/lib/live_debugger/app/utils/term_node.ex
new file mode 100644
index 000000000..8271c5bae
--- /dev/null
+++ b/lib/live_debugger/app/utils/term_node.ex
@@ -0,0 +1,142 @@
+defmodule LiveDebugger.App.Utils.TermNode do
+ @moduledoc """
+ Represents a node in the display tree.
+ Based on [Kino.Tree](https://github.com/livebook-dev/kino/blob/main/lib/kino/tree.ex)
+
+ - `id`: The id of the node. It uses dot notation to represent the path to the node.
+ - `open?`: Whether the node is expanded
+ - `kind`: The type of the node (e.g., :atom, :list, :map).
+ - `children`: A map of child nodes with keys (integer indices for lists/tuples, actual keys for maps).
+ - `content`: Display elements that represent the content of the node when has no children or not expanded.
+ - `expanded_before`: Display elements shown before the node's children when expanded.
+ - `expanded_after`: Display elements shown after the node's children when expanded.
+ """
+
+ defmodule DisplayElement do
+ @moduledoc false
+ defstruct [:text, color: nil]
+
+ @type t :: %__MODULE__{
+ text: String.t(),
+ color: String.t() | nil
+ }
+
+ @spec blue(String.t()) :: t()
+ def blue(text), do: %__MODULE__{text: text, color: "text-code-1"}
+
+ @spec black(String.t()) :: t()
+ def black(text), do: %__MODULE__{text: text, color: "text-code-2"}
+
+ @spec magenta(String.t()) :: t()
+ def magenta(text), do: %__MODULE__{text: text, color: "text-code-3"}
+
+ @spec green(String.t()) :: t()
+ def green(text), do: %__MODULE__{text: text, color: "text-code-4"}
+ end
+
+ defstruct [:id, :kind, :children, :content, :expanded_before, :expanded_after, open?: false]
+
+ @type kind() :: :atom | :binary | :number | :tuple | :list | :map | :struct | :regex | :other
+
+ @type t :: %__MODULE__{
+ id: String.t(),
+ kind: kind(),
+ open?: boolean(),
+ children: [{any(), t()}],
+ content: [DisplayElement.t()],
+ expanded_before: [DisplayElement.t()],
+ expanded_after: [DisplayElement.t()]
+ }
+
+ @type ok_error() :: {:ok, t()} | {:error, any()}
+
+ @spec new(kind(), [DisplayElement.t()], Keyword.t()) :: t()
+ def new(kind, content, args \\ []) do
+ children = Keyword.get(args, :children, [])
+ expanded_before = Keyword.get(args, :expanded_before, [])
+ expanded_after = Keyword.get(args, :expanded_after, [])
+ open? = Keyword.get(args, :open?, false)
+ id = Keyword.get(args, :id, "not_indexed")
+
+ %__MODULE__{
+ id: id,
+ kind: kind,
+ content: content,
+ children: children,
+ expanded_before: expanded_before,
+ expanded_after: expanded_after,
+ open?: open?
+ }
+ end
+
+ @spec comma_suffix() :: DisplayElement.t()
+ def comma_suffix(), do: DisplayElement.black(",")
+
+ @spec has_children?(t()) :: boolean()
+ def has_children?(%__MODULE__{children: []}), do: false
+ def has_children?(%__MODULE__{}), do: true
+
+ @spec children_number(t()) :: integer()
+ def children_number(%__MODULE__{children: children}), do: length(children)
+
+ @spec add_suffix(t(), [DisplayElement.t()]) :: t()
+ def add_suffix(
+ %__MODULE__{content: content, expanded_after: expanded_after} = term_node,
+ suffix
+ )
+ when is_list(suffix) do
+ content = content ++ suffix
+ expanded_after = expanded_after ++ suffix
+ %__MODULE__{term_node | content: content, expanded_after: expanded_after}
+ end
+
+ @spec remove_suffix!(t()) :: t()
+ def remove_suffix!(%__MODULE__{content: [_ | _], expanded_after: [_ | _]} = term_node) do
+ content = term_node.content |> Enum.reverse() |> tl() |> Enum.reverse()
+ expanded_after = term_node.expanded_after |> Enum.reverse() |> tl() |> Enum.reverse()
+
+ %__MODULE__{term_node | content: content, expanded_after: expanded_after}
+ end
+
+ def remove_suffix!(%__MODULE__{}), do: raise("Term node has no suffix to remove")
+
+ @spec add_prefix(t(), [DisplayElement.t()]) :: t()
+ def add_prefix(
+ %__MODULE__{content: content, expanded_before: expanded_before} = term_node,
+ prefix
+ )
+ when is_list(prefix) do
+ content = prefix ++ content
+ expanded_before = prefix ++ expanded_before
+ %__MODULE__{term_node | content: content, expanded_before: expanded_before}
+ end
+
+ @spec remove_child(t(), any()) :: ok_error()
+ def remove_child(%__MODULE__{children: children} = term, key) do
+ children
+ |> Enum.find_index(fn {child_key, _} -> child_key == key end)
+ |> case do
+ nil ->
+ {:error, :child_not_found}
+
+ index ->
+ children = List.delete_at(children, index)
+ {:ok, %__MODULE__{term | children: children}}
+ end
+ end
+
+ @spec update_child(t(), any(), (t() -> t())) :: ok_error()
+ def update_child(%__MODULE__{children: children} = term, key, update_fn) do
+ children
+ |> Enum.with_index()
+ |> Enum.filter(fn {{child_key, _}, _} -> child_key == key end)
+ |> case do
+ [{{^key, child}, index}] ->
+ children = List.replace_at(children, index, {key, update_fn.(child)})
+ {:ok, %__MODULE__{term | children: children}}
+
+ _ ->
+ {:error, :child_not_found}
+ end
+ end
+end
diff --git a/lib/live_debugger/app/utils/term_parser.ex b/lib/live_debugger/app/utils/term_parser.ex
index c3bf59c8e..6a6bbd4b2 100644
--- a/lib/live_debugger/app/utils/term_parser.ex
+++ b/lib/live_debugger/app/utils/term_parser.ex
@@ -1,44 +1,18 @@
defmodule LiveDebugger.App.Utils.TermParser do
@moduledoc """
- This module provides functions to parse terms into display tree.
- Based on [Kino.Tree](https://github.com/livebook-dev/kino/blob/main/lib/kino/tree.ex)
+ This module provides functions to parse elixir terms.
"""
- defmodule DisplayElement do
- @moduledoc false
- defstruct [:text, color: nil]
+ alias LiveDebugger.App.Utils.TermDiffer.Diff
+ alias LiveDebugger.App.Utils.TermDiffer
+ alias LiveDebugger.App.Utils.TermNode.DisplayElement
+ alias LiveDebugger.App.Utils.TermNode
- @type t :: %__MODULE__{
- text: String.t(),
- color: String.t() | nil
- }
- end
-
- defmodule TermNode do
- @moduledoc """
- Represents a node in the display tree.
-
- - `id`: The id of the node (it represents the path to the node in the tree).
- - `kind`: The type of the node (e.g., :atom, :list, :map).
- - `children`: A list of child nodes.
- - `content`: Display elements that represent the content of the node when has no children or not expanded.
- - `expanded_before`: Display elements shown before the node's children when expanded.
- - `expanded_after`: Display elements shown after the node's children when expanded.
- """
- defstruct [:id, :kind, :children, :content, :expanded_before, :expanded_after]
-
- @type kind() :: :atom | :binary | :number | :tuple | :list | :map | :struct | :regex | :other
-
- @type t :: %__MODULE__{
- id: String.t(),
- kind: kind(),
- children: [t()],
- content: [DisplayElement.t()],
- expanded_before: [DisplayElement.t()] | nil,
- expanded_after: [DisplayElement.t()] | nil
- }
- end
+ @list_and_tuple_open_limit 3
+ @doc """
+ Convert term into infinite string which can be copied to IEx console.
+ """
@spec term_to_copy_string(term()) :: String.t()
def term_to_copy_string(term) do
term
@@ -50,178 +24,404 @@ defmodule LiveDebugger.App.Utils.TermParser do
|> String.replace(~r/#.+?<.*?>/, &"\"#{&1}\"")
end
+ @doc """
+ It creates a TermNode tree ready for display.
+
+ It includes:
+ - Indexing the tree
+ - Adding comma suffixes
+ - Opening proper elements
+ """
@spec term_to_display_tree(term()) :: TermNode.t()
def term_to_display_tree(term) do
- to_node(term, [], "root")
+ term
+ |> to_node()
+ |> index_term_node()
+ |> update_comma_suffixes()
+ |> default_open_settings()
+ end
+
+ @doc """
+ Updates the term node by id.
+
+ ## Examples
+
+ iex> term_node = TermParser.term_to_display_tree(term)
+ iex> update_by_id(term_node, "root.0", fn term_node ->
+ ...> %{term_node | content: [DisplayElement.blue("new value")]}
+ ...> end)
+ {:ok, %TermNode{...}}
+ """
+ @spec update_by_id(TermNode.t(), String.t(), (TermNode.t() -> TermNode.t())) ::
+ TermNode.ok_error()
+ def update_by_id(term_node, path, update_fn)
+
+ def update_by_id(term_node, "root", update_fn) do
+ {:ok, update_fn.(term_node)}
+ end
+
+ def update_by_id(term_node, "root" <> _ = id, update_fn) do
+ ["root" | string_path] = String.split(id, ".")
+ path = string_path |> Enum.map(&String.to_integer/1)
+ update_by_path(term_node, path, update_fn)
+ end
+
+ @doc """
+ Updates the term node using calculated term #{Diff}.
+
+ ## Examples
+
+ iex> term_node = TermParser.term_to_display_tree(term)
+ iex> diff = TermDiffer.diff(term, new_term)
+ iex> update_by_diff(term_node, diff)
+ {:ok, %TermNode{...}}
+ """
+ @spec update_by_diff(TermNode.t(), Diff.t()) :: TermNode.ok_error()
+ def update_by_diff(term_node, diff) do
+ term_node =
+ term_node
+ |> update_by_diff!(diff)
+ |> index_term_node()
+ |> update_comma_suffixes()
+
+ {:ok, term_node}
+ rescue
+ error ->
+ {:error, "Invalid diff or term node: #{inspect(error)}"}
+ end
+
+ @spec update_by_path(TermNode.t(), [integer()], (TermNode.t() -> TermNode.t())) ::
+ TermNode.ok_error()
+ defp update_by_path(term, [index | path], update_fn) do
+ with {key, child} <- Enum.at(term.children, index),
+ {:ok, updated_child} <- update_by_path(child, path, update_fn) do
+ children = List.replace_at(term.children, index, {key, updated_child})
+ {:ok, %TermNode{term | children: children}}
+ else
+ nil ->
+ {:error, :child_not_found}
+
+ {:error, reason} ->
+ {:error, reason}
+ end
+ end
+
+ defp update_by_path(term, [], update_fn) do
+ {:ok, update_fn.(term)}
+ end
+
+ @spec update_by_diff!(TermNode.t(), Diff.t(), Keyword.t()) :: TermNode.t()
+ defp update_by_diff!(term_node, diff, opts \\ [])
+
+ defp update_by_diff!(term_node, %Diff{type: :equal}, _opts), do: term_node
+
+ defp update_by_diff!(_, %Diff{type: :primitive} = diff, opts) do
+ term = TermDiffer.primitive_new_value(diff)
+
+ case Keyword.get(opts, :key, nil) do
+ nil ->
+ to_node(term)
+
+ key ->
+ {key, term}
+ |> to_key_value_node()
+ |> elem(1)
+ end
+ end
+
+ defp update_by_diff!(term_node, %Diff{type: type, ins: ins, del: del}, _opts)
+ when type in [:list, :tuple] do
+ term_node
+ |> term_node_reduce_del(del)
+ |> term_node_reduce_list_ins(ins)
+ end
+
+ defp update_by_diff!(term_node, %Diff{type: :struct, diff: diff}, _opts) do
+ term_node_reduce_diff!(term_node, diff)
+ end
+
+ defp update_by_diff!(term_node, %Diff{type: :map, ins: ins, del: del, diff: diff}, _opts) do
+ term_node
+ |> term_node_reduce_del(del)
+ |> term_node_reduce_map_ins(ins)
+ |> term_node_reduce_diff!(diff)
+ end
+
+ defp term_node_reduce_del(term_node, del) do
+ Enum.reduce(del, term_node, fn {key, _}, term_node_acc ->
+ {:ok, term_node_acc} = TermNode.remove_child(term_node_acc, key)
+ term_node_acc
+ end)
+ end
+
+ defp term_node_reduce_list_ins(term_node, ins) do
+ Enum.reduce(ins, term_node, fn {index, term}, term_node_acc ->
+ children = List.insert_at(term_node_acc.children, index, {index, to_node(term)})
+ %TermNode{term_node_acc | children: children}
+ end)
+ end
+
+ defp term_node_reduce_map_ins(term_node, ins) do
+ child_keys = term_node.children |> Enum.map(fn {key, _} -> key end)
+
+ {term_node_acc, _} =
+ Enum.reduce(ins, {term_node, child_keys}, fn {key, term}, {term_node_acc, child_keys} ->
+ child_keys = Enum.sort(child_keys ++ [key])
+ index = Enum.find_index(child_keys, &(&1 == key))
+
+ children = List.insert_at(term_node_acc.children, index, to_key_value_node({key, term}))
+ {%TermNode{term_node_acc | children: children}, child_keys}
+ end)
+
+ term_node_acc
end
- @spec to_node(term(), [DisplayElement.t()], String.t()) :: TermNode.t()
- defp to_node(string, suffix, id_path) when is_binary(string) do
- leaf_node(id_path, :binary, [green(inspect(string)) | suffix])
+ defp term_node_reduce_diff!(term_node, diff) do
+ Enum.reduce(diff, term_node, fn {key, child_diff}, term_node_acc ->
+ {:ok, term_node_acc} =
+ TermNode.update_child(term_node_acc, key, fn child ->
+ update_by_diff!(child, child_diff, key: key)
+ end)
+
+ term_node_acc
+ end)
end
- defp to_node(atom, suffix, id_path) when is_atom(atom) do
+ @spec index_term_node(TermNode.t()) :: TermNode.t()
+ defp index_term_node(%TermNode{children: children} = term_node, id_path \\ "root") do
+ new_children =
+ children
+ |> Enum.with_index()
+ |> Enum.map(fn {{key, child}, idx} ->
+ {key, index_term_node(child, "#{id_path}.#{idx}")}
+ end)
+
+ %TermNode{term_node | children: new_children, id: id_path}
+ end
+
+ @spec update_comma_suffixes(TermNode.t()) :: TermNode.t()
+ defp update_comma_suffixes(%TermNode{children: []} = term_node), do: term_node
+
+ defp update_comma_suffixes(%TermNode{children: children} = term_node) do
+ size = length(children)
+
+ children =
+ Enum.with_index(children, fn
+ {key, child}, index ->
+ child = update_comma_suffixes(child)
+
+ last_child? = index == size - 1
+
+ child =
+ case {last_child?, has_comma_suffix?(child)} do
+ {true, true} ->
+ TermNode.remove_suffix!(child)
+
+ {false, false} ->
+ TermNode.add_suffix(child, [TermNode.comma_suffix()])
+
+ _ ->
+ child
+ end
+
+ {key, child}
+ end)
+
+ %TermNode{term_node | children: children}
+ end
+
+ @spec to_node(term()) :: TermNode.t()
+ defp to_node(string) when is_binary(string) do
+ TermNode.new(:binary, [DisplayElement.green(inspect(string))])
+ end
+
+ defp to_node(atom) when is_atom(atom) do
span =
if atom in [nil, true, false] do
- magenta(inspect(atom))
+ DisplayElement.magenta(inspect(atom))
else
- blue(inspect(atom))
+ DisplayElement.blue(inspect(atom))
end
- leaf_node(id_path, :atom, [span | suffix])
+ TermNode.new(:atom, [span])
end
- defp to_node(number, suffix, id_path) when is_number(number) do
- leaf_node(id_path, :number, [blue(inspect(number)) | suffix])
+ defp to_node(number) when is_number(number) do
+ TermNode.new(:number, [DisplayElement.blue(inspect(number))])
end
- defp to_node({}, suffix, id_path) do
- leaf_node(id_path, :tuple, [black("{}") | suffix])
+ defp to_node({}) do
+ TermNode.new(:tuple, [DisplayElement.black("{}")],
+ open?: false,
+ expanded_before: [DisplayElement.black("{")],
+ expanded_after: [DisplayElement.black("}")]
+ )
end
- defp to_node(tuple, suffix, id_path) when is_tuple(tuple) do
- size = tuple_size(tuple)
- children = tuple |> Tuple.to_list() |> to_children(size, id_path)
+ defp to_node(tuple) when is_tuple(tuple) do
+ children = tuple |> Tuple.to_list() |> to_children()
- branch_node(id_path, :tuple, [black("{...}") | suffix], children, [black("{")], [
- black("}") | suffix
- ])
+ TermNode.new(:tuple, [DisplayElement.black("{...}")],
+ children: children,
+ expanded_before: [DisplayElement.black("{")],
+ expanded_after: [DisplayElement.black("}")]
+ )
end
- defp to_node([], suffix, id_path) do
- leaf_node(id_path, :list, [black("[]") | suffix])
+ defp to_node([]) do
+ TermNode.new(:list, [DisplayElement.black("[]")],
+ open?: false,
+ expanded_before: [DisplayElement.black("[")],
+ expanded_after: [DisplayElement.black("]")]
+ )
end
- defp to_node(list, suffix, id_path) when is_list(list) do
- size = length(list)
-
+ defp to_node(list) when is_list(list) do
children =
if Keyword.keyword?(list) do
- to_key_value_children(list, size, id_path)
+ to_key_value_children(list)
else
- to_children(list, size, id_path)
+ to_children(list)
end
- branch_node(id_path, :list, [black("[...]") | suffix], children, [black("[")], [
- black("]") | suffix
- ])
+ TermNode.new(:list, [DisplayElement.black("[...]")],
+ children: children,
+ expanded_before: [DisplayElement.black("[")],
+ expanded_after: [DisplayElement.black("]")]
+ )
end
- defp to_node(%Regex{} = regex, suffix, id_path) do
- leaf_node(id_path, :regex, [black(inspect(regex)) | suffix])
+ defp to_node(%Regex{} = regex) do
+ TermNode.new(:regex, [DisplayElement.black(inspect(regex))])
end
- defp to_node(%module{} = struct, suffix, id_path) when is_struct(struct) do
+ defp to_node(%module{} = struct) when is_struct(struct) do
content =
if Inspect.impl_for(struct) in [Inspect.Any, Inspect.Phoenix.LiveView.Socket] do
- [black("%"), blue(inspect(module)), black("{...}") | suffix]
+ [
+ DisplayElement.black("%"),
+ DisplayElement.blue(inspect(module)),
+ DisplayElement.black("{...}")
+ ]
else
- [black(inspect(struct)) | suffix]
+ [DisplayElement.black(inspect(struct))]
end
- map = Map.from_struct(struct)
- size = map_size(map)
- children = to_key_value_children(map, size, id_path)
+ children =
+ struct
+ |> Map.from_struct()
+ |> Map.to_list()
+ |> to_key_value_children()
- branch_node(
- id_path,
+ TermNode.new(
:struct,
content,
- children,
- [black("%"), blue(inspect(module)), black("{")],
- [black("}") | suffix]
+ children: children,
+ expanded_before: [
+ DisplayElement.black("%"),
+ DisplayElement.blue(inspect(module)),
+ DisplayElement.black("{")
+ ],
+ expanded_after: [DisplayElement.black("}")]
)
end
- defp to_node(%{} = map, suffix, id_path) when map_size(map) == 0 do
- leaf_node(id_path, :map, [black("%{}") | suffix])
+ defp to_node(%{} = map) when map_size(map) == 0 do
+ TermNode.new(:map, [DisplayElement.black("%{}")],
+ expanded_before: [DisplayElement.black("%{")],
+ expanded_after: [DisplayElement.black("}")]
+ )
end
- defp to_node(map, suffix, id_path) when is_map(map) do
- size = map_size(map)
- children = map |> Enum.sort() |> to_key_value_children(size, id_path)
+ defp to_node(map) when is_map(map) do
+ children = map |> Enum.sort() |> to_key_value_children()
- branch_node(id_path, :map, [black("%{...}") | suffix], children, [black("%{")], [
- black("}") | suffix
- ])
+ TermNode.new(:map, [DisplayElement.black("%{...}")],
+ children: children,
+ expanded_before: [DisplayElement.black("%{")],
+ expanded_after: [DisplayElement.black("}")]
+ )
end
- defp to_node(other, suffix, id_path) do
- leaf_node(id_path, :other, [black(inspect(other)) | suffix])
+ defp to_node(other) do
+ TermNode.new(:other, [DisplayElement.black(inspect(other))])
end
- defp to_key_value_node({key, value}, suffix, id_path) do
+ defp to_key_value_node({key, value}) do
{key_span, sep_span} =
- case to_node(key, [], "not_used_id_path") do
+ case to_node(key) do
%TermNode{content: [%DisplayElement{text: ":" <> name} = span]} when is_atom(key) ->
- {%{span | text: name <> ":"}, black(" ")}
+ {%{span | text: name <> ":"}, DisplayElement.black(" ")}
%TermNode{content: [span]} ->
- {%{span | text: inspect(key, width: :infinity)}, black(" => ")}
+ {%{span | text: inspect(key, width: :infinity)}, DisplayElement.black(" => ")}
%TermNode{content: _content} ->
{%DisplayElement{text: inspect(key, width: :infinity), color: "text-code-1"},
- black(" => ")}
+ DisplayElement.black(" => ")}
end
- case to_node(value, suffix, id_path) do
- %TermNode{content: content, children: []} = node ->
- %TermNode{node | content: [key_span, sep_span | content]}
+ node = value |> to_node() |> TermNode.add_prefix([key_span, sep_span])
- %TermNode{content: content, expanded_before: expanded_before} = node ->
- %TermNode{
- node
- | content: [key_span, sep_span | content],
- expanded_before: [key_span, sep_span | expanded_before]
- }
- end
+ {key, node}
end
- defp to_children(items, container_size, id_path) do
+ defp to_children(items) when is_list(items) do
Enum.with_index(items, fn item, index ->
- to_node(item, suffix(index, container_size), "#{id_path}.#{index}")
+ {index, to_node(item)}
end)
end
- defp to_key_value_children(items, container_size, id_path) do
- Enum.with_index(items, fn item, index ->
- to_key_value_node(item, suffix(index, container_size), "#{id_path}.#{index}")
- end)
+ defp to_key_value_children(items) when is_list(items) do
+ Enum.map(items, &to_key_value_node/1)
end
- defp suffix(index, container_size) do
- if index != container_size - 1 do
- [black(",")]
+ defp default_open_settings(term_node) do
+ term_node
+ |> open_first_element()
+ |> open_small_lists_and_tuples()
+ end
+
+ defp open_first_element(term_node) when is_struct(term_node, TermNode) do
+ %TermNode{term_node | open?: true}
+ end
+
+ defp open_small_lists_and_tuples(%TermNode{kind: kind, children: children} = term_node)
+ when kind in [:list, :tuple] do
+ children =
+ Enum.map(children, fn {key, child} ->
+ {key, open_small_lists_and_tuples(child)}
+ end)
+
+ if length(children) < @list_and_tuple_open_limit do
+ %TermNode{term_node | open?: true, children: children}
else
- []
+ %TermNode{term_node | children: children}
end
end
- defp leaf_node(id_path, kind, content) when is_binary(id_path) and is_atom(kind) do
- %TermNode{
- id: id_path,
- kind: kind,
- content: content,
- children: [],
- expanded_before: nil,
- expanded_after: nil
- }
+ defp open_small_lists_and_tuples(%TermNode{children: children} = term_node) do
+ children =
+ Enum.map(children, fn {key, child} ->
+ {key, open_small_lists_and_tuples(child)}
+ end)
+
+ %TermNode{term_node | children: children}
+ end
+
+ defp has_comma_suffix?(%TermNode{content: content, expanded_after: expanded_after}) do
+ content? = last_item_equal?(content, TermNode.comma_suffix())
+ expanded_after? = last_item_equal?(expanded_after, TermNode.comma_suffix())
+
+ if content? != expanded_after?,
+ do: raise("Content and expanded_after must have the same comma suffix")
+
+ content?
end
- defp branch_node(id_path, kind, content, children, expanded_before, expanded_after)
- when is_binary(id_path) and is_atom(kind) do
- %TermNode{
- id: id_path,
- kind: kind,
- content: content,
- children: children,
- expanded_before: expanded_before,
- expanded_after: expanded_after
- }
+ defp last_item_equal?([_ | _] = items, item) do
+ items |> Enum.reverse() |> hd() |> Kernel.==(item)
end
- defp blue(text), do: %DisplayElement{text: text, color: "text-code-1"}
- defp black(text), do: %DisplayElement{text: text, color: "text-code-2"}
- defp magenta(text), do: %DisplayElement{text: text, color: "text-code-3"}
- defp green(text), do: %DisplayElement{text: text, color: "text-code-4"}
+ defp last_item_equal?([], _), do: false
end
diff --git a/lib/live_debugger/app/web/components.ex b/lib/live_debugger/app/web/components.ex
index 155bad397..673b087da 100644
--- a/lib/live_debugger/app/web/components.ex
+++ b/lib/live_debugger/app/web/components.ex
@@ -165,6 +165,45 @@ defmodule LiveDebugger.App.Web.Components do
"""
end
+ @doc """
+ Static collapsible element. It doesn't perform any client-side actions.
+
+
+ ## Examples
+
+ <.static_collapsible id="collapsible" open={true}>
+ <:label :let={open}>
+ <%= if(open, do: "Open", else: "Closed") %>
+
+
Content
+
+ """
+
+ attr(:open, :boolean, required: true, doc: "State of the collapsible")
+ attr(:class, :any, default: nil, doc: "CSS class for parent container")
+ attr(:label_class, :any, default: nil, doc: "CSS class for the label")
+ attr(:chevron_class, :any, default: nil, doc: "CSS class for the chevron icon")
+
+ attr(:rest, :global)
+
+ slot(:label, required: true)
+ slot(:inner_block, required: true)
+
+ def static_collapsible(assigns) do
+ ~H"""
+
+
+ <.icon
+ name="icon-chevron-right"
+ class={["shrink-0", if(@open, do: "rotate-90") | List.wrap(@chevron_class)]}
+ />
+ <%= render_slot(@label, @open) %>
+
+ <%= if(@open, do: render_slot(@inner_block)) %>
+
+ """
+ end
+
@doc """
Renders flash notices.
diff --git a/test/app/debugger/node_state/queries_test.exs b/test/app/debugger/node_state/queries_test.exs
index 2dc7fe2c2..f09703fbb 100644
--- a/test/app/debugger/node_state/queries_test.exs
+++ b/test/app/debugger/node_state/queries_test.exs
@@ -18,7 +18,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.QueriesTest do
MockAPIStatesStorage
|> expect(:get!, fn ^pid -> %LvState{socket: %{assigns: assigns}} end)
- assert {:ok, %{node_assigns: ^assigns}} = NodeStateQueries.fetch_node_assigns(pid, pid)
+ assert {:ok, ^assigns} = NodeStateQueries.fetch_node_assigns(pid, pid)
end
test "returns assigns for a valid LiveView node when not saved in storage" do
@@ -32,7 +32,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.QueriesTest do
|> expect(:socket, fn ^pid -> {:ok, %{assigns: assigns}} end)
|> expect(:live_components, fn ^pid -> {:ok, []} end)
- assert {:ok, %{node_assigns: ^assigns}} = NodeStateQueries.fetch_node_assigns(pid, pid)
+ assert {:ok, ^assigns} = NodeStateQueries.fetch_node_assigns(pid, pid)
end
test "returns assigns for a valid component CID when saved in storage" do
@@ -43,7 +43,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.QueriesTest do
MockAPIStatesStorage
|> expect(:get!, fn ^pid -> %LvState{components: [%{cid: cid.cid, assigns: assigns}]} end)
- assert {:ok, %{node_assigns: ^assigns}} = NodeStateQueries.fetch_node_assigns(pid, cid)
+ assert {:ok, ^assigns} = NodeStateQueries.fetch_node_assigns(pid, cid)
end
test "returns assigns for a valid component CID when not saved in storage" do
@@ -58,7 +58,7 @@ defmodule LiveDebugger.App.Debugger.NodeState.QueriesTest do
|> expect(:socket, fn ^pid -> {:ok, %{}} end)
|> expect(:live_components, fn ^pid -> {:ok, [%{cid: cid.cid, assigns: assigns}]} end)
- assert {:ok, %{node_assigns: ^assigns}} = NodeStateQueries.fetch_node_assigns(pid, cid)
+ assert {:ok, ^assigns} = NodeStateQueries.fetch_node_assigns(pid, cid)
end
end
end
diff --git a/test/app/utils/term_node_test.exs b/test/app/utils/term_node_test.exs
new file mode 100644
index 000000000..e9f9a5f65
--- /dev/null
+++ b/test/app/utils/term_node_test.exs
@@ -0,0 +1,173 @@
+defmodule LiveDebugger.App.Utils.TermNodeTest do
+ use ExUnit.Case, async: true
+
+ alias LiveDebugger.App.Utils.TermNode
+ alias LiveDebugger.App.Utils.TermNode.DisplayElement
+ alias LiveDebugger.Fakes
+
+ describe "new/3" do
+ test "creates a new term node with default values" do
+ content = [%DisplayElement{text: "atom", color: "text-code-1"}]
+ kind = :atom
+
+ assert %TermNode{
+ id: "not_indexed",
+ kind: :atom,
+ content: ^content,
+ children: [],
+ expanded_before: [],
+ expanded_after: [],
+ open?: false
+ } = TermNode.new(kind, content)
+ end
+
+ test "creates a new term node with custom values" do
+ id = "custom_id"
+ content = [Fakes.display_element()]
+ kind = :atom
+
+ children = [
+ Fakes.term_node(id: "not_indexed", content: [Fakes.display_element(text: "child")])
+ ]
+
+ expanded_before = [Fakes.display_element(text: "expanded_before")]
+ expanded_after = [Fakes.display_element(text: "expanded_after")]
+ open? = true
+
+ assert %TermNode{
+ id: ^id,
+ kind: ^kind,
+ content: ^content,
+ children: ^children,
+ expanded_before: ^expanded_before,
+ expanded_after: ^expanded_after,
+ open?: ^open?
+ } =
+ TermNode.new(kind, content,
+ id: id,
+ children: children,
+ expanded_before: expanded_before,
+ expanded_after: expanded_after,
+ open?: open?
+ )
+ end
+ end
+
+ describe "has_children?/1" do
+ test "returns true if the term node has children" do
+ assert TermNode.has_children?(%TermNode{children: [1, 2, 3]})
+ end
+
+ test "returns false if the term node has no children" do
+ assert not TermNode.has_children?(%TermNode{children: []})
+ end
+ end
+
+ describe "children_number/1" do
+ test "returns the number of children" do
+ assert TermNode.children_number(%TermNode{children: [1, 2, 3]}) == 3
+ end
+
+ test "returns 0 if the term node has no children" do
+ assert TermNode.children_number(%TermNode{children: []}) == 0
+ end
+ end
+
+ describe "add_suffix/2 properly adds suffix to the term node's content and expanded_after" do
+ content = Fakes.display_element(text: "content")
+
+ term_node = Fakes.term_node(id: "root", content: [content])
+
+ suffix = Fakes.display_element(text: "suffix")
+
+ assert %TermNode{
+ content: [^content, ^suffix],
+ children: [],
+ expanded_before: [],
+ expanded_after: [^suffix]
+ } = TermNode.add_suffix(term_node, [suffix])
+ end
+
+ describe "remove_suffix!/1" do
+ test "removes last element from the term node's content and expanded_after" do
+ suffix = Fakes.display_element(text: "suffix")
+
+ term_node = Fakes.term_node(id: "root", content: [suffix], expanded_after: [suffix])
+
+ assert %TermNode{
+ id: "root",
+ kind: :atom,
+ content: [],
+ children: [],
+ expanded_before: [],
+ expanded_after: []
+ } = TermNode.remove_suffix!(term_node)
+ end
+
+ test "raises an error if the term node has no suffix to remove" do
+ assert_raise RuntimeError, "Term node has no suffix to remove", fn ->
+ TermNode.remove_suffix!(%TermNode{content: [], expanded_after: []})
+ end
+ end
+ end
+
+ test "add_prefix/2 properly adds prefix to the term node's content and expanded_before" do
+ content = Fakes.display_element(text: "content")
+
+ term_node = Fakes.term_node(id: "root", content: [content])
+
+ prefix = Fakes.display_element(text: "prefix")
+
+ assert %TermNode{
+ content: [^prefix, ^content],
+ children: [],
+ expanded_before: [^prefix],
+ expanded_after: []
+ } = TermNode.add_prefix(term_node, [prefix])
+ end
+
+ describe "remove_child/2" do
+ test "removes the child from the term node and returns ok tuple" do
+ child2 = Fakes.term_node(id: "root.1")
+ child1 = Fakes.term_node(id: "root.0")
+
+ term_node = Fakes.term_node(kind: :tuple, id: "root", children: [{0, child1}, {1, child2}])
+
+ assert {:ok,
+ %TermNode{
+ id: "root",
+ kind: :tuple,
+ children: [{0, ^child1}]
+ }} = TermNode.remove_child(term_node, 1)
+ end
+
+ test "returns error if the child is not found" do
+ child1 = Fakes.term_node(id: "root.0")
+ term_node = Fakes.term_node(kind: :tuple, id: "root", children: [{0, child1}])
+
+ assert {:error, :child_not_found} = TermNode.remove_child(term_node, 1)
+ end
+ end
+
+ describe "update_child/3" do
+ test "updates the child and returns ok tuple" do
+ child = Fakes.term_node(id: "root.0")
+ term_node = Fakes.term_node(kind: :tuple, id: "root", children: [{0, child}])
+
+ updated_content = [Fakes.display_element(text: "updated")]
+
+ update_function = fn child -> %TermNode{child | content: updated_content} end
+
+ assert {:ok, %TermNode{children: [{0, %TermNode{content: ^updated_content}}]}} =
+ TermNode.update_child(term_node, 0, update_function)
+ end
+
+ test "returns error if the child is not found" do
+ child = Fakes.term_node(id: "root.0")
+ term_node = Fakes.term_node(kind: :tuple, id: "root", children: [{0, child}])
+
+ assert {:error, :child_not_found} =
+ TermNode.update_child(term_node, 1, &Function.identity/1)
+ end
+ end
+end
diff --git a/test/app/utils/term_parser_test.exs b/test/app/utils/term_parser_test.exs
index fb40c48d4..90233703a 100644
--- a/test/app/utils/term_parser_test.exs
+++ b/test/app/utils/term_parser_test.exs
@@ -1,9 +1,12 @@
defmodule LiveDebugger.App.Utils.TermParserTest do
use ExUnit.Case, async: true
- alias LiveDebugger.App.Utils.TermParser.DisplayElement
- alias LiveDebugger.App.Utils.TermParser.TermNode
+ alias LiveDebugger.App.Utils.TermNode
+ alias LiveDebugger.App.Utils.TermNode.DisplayElement
alias LiveDebugger.App.Utils.TermParser
+ alias LiveDebugger.App.Utils.TermDiffer
+ alias LiveDebugger.App.Utils.TermDiffer.Diff
+ alias LiveDebugger.Fakes
defmodule TestStruct do
defstruct [:field1, :field2]
@@ -16,10 +19,11 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
expected = %TermNode{
id: "root",
kind: :binary,
+ open?: true,
children: [],
content: [%DisplayElement{text: "\"Hello, World!\"", color: "text-code-4"}],
- expanded_before: nil,
- expanded_after: nil
+ expanded_before: [],
+ expanded_after: []
}
assert TermParser.term_to_display_tree(term) == expected
@@ -31,10 +35,11 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
expected = %TermNode{
id: "root",
kind: :atom,
+ open?: true,
children: [],
content: [%DisplayElement{text: ":hello", color: "text-code-1"}],
- expanded_before: nil,
- expanded_after: nil
+ expanded_before: [],
+ expanded_after: []
}
assert TermParser.term_to_display_tree(term) == expected
@@ -46,10 +51,11 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
expected = %TermNode{
id: "root",
kind: :number,
+ open?: true,
children: [],
content: [%DisplayElement{text: "42", color: "text-code-1"}],
- expanded_before: nil,
- expanded_after: nil
+ expanded_before: [],
+ expanded_after: []
}
assert TermParser.term_to_display_tree(term) == expected
@@ -61,26 +67,31 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
expected = %TermNode{
id: "root",
kind: :tuple,
+ open?: true,
children: [
- %TermNode{
- id: "root.0",
- kind: :atom,
- children: [],
- content: [
- %DisplayElement{text: ":ok", color: "text-code-1"},
- %DisplayElement{text: ",", color: "text-code-2"}
- ],
- expanded_before: nil,
- expanded_after: nil
- },
- %TermNode{
- id: "root.1",
- kind: :binary,
- children: [],
- content: [%DisplayElement{text: "\"Hello\"", color: "text-code-4"}],
- expanded_before: nil,
- expanded_after: nil
- }
+ {0,
+ %TermNode{
+ id: "root.0",
+ kind: :atom,
+ open?: false,
+ children: [],
+ content: [
+ %DisplayElement{text: ":ok", color: "text-code-1"},
+ %DisplayElement{text: ",", color: "text-code-2"}
+ ],
+ expanded_before: [],
+ expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}]
+ }},
+ {1,
+ %TermNode{
+ id: "root.1",
+ kind: :binary,
+ open?: false,
+ children: [],
+ content: [%DisplayElement{text: "\"Hello\"", color: "text-code-4"}],
+ expanded_before: [],
+ expanded_after: []
+ }}
],
content: [%DisplayElement{text: "{...}", color: "text-code-2"}],
expanded_before: [%DisplayElement{text: "{", color: "text-code-2"}],
@@ -96,10 +107,11 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
expected = %TermNode{
id: "root",
kind: :tuple,
+ open?: true,
children: [],
content: [%DisplayElement{text: "{}", color: "text-code-2"}],
- expanded_before: nil,
- expanded_after: nil
+ expanded_before: [%DisplayElement{text: "{", color: "text-code-2"}],
+ expanded_after: [%DisplayElement{text: "}", color: "text-code-2"}]
}
assert TermParser.term_to_display_tree(term) == expected
@@ -111,37 +123,44 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
expected = %TermNode{
id: "root",
kind: :list,
+ open?: true,
children: [
- %TermNode{
- id: "root.0",
- kind: :number,
- children: [],
- content: [
- %DisplayElement{text: "1", color: "text-code-1"},
- %DisplayElement{text: ",", color: "text-code-2"}
- ],
- expanded_before: nil,
- expanded_after: nil
- },
- %TermNode{
- id: "root.1",
- kind: :number,
- children: [],
- content: [
- %DisplayElement{text: "2", color: "text-code-1"},
- %DisplayElement{text: ",", color: "text-code-2"}
- ],
- expanded_before: nil,
- expanded_after: nil
- },
- %TermNode{
- id: "root.2",
- kind: :number,
- children: [],
- content: [%DisplayElement{text: "3", color: "text-code-1"}],
- expanded_before: nil,
- expanded_after: nil
- }
+ {0,
+ %TermNode{
+ id: "root.0",
+ kind: :number,
+ open?: false,
+ children: [],
+ content: [
+ %DisplayElement{text: "1", color: "text-code-1"},
+ %DisplayElement{text: ",", color: "text-code-2"}
+ ],
+ expanded_before: [],
+ expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}]
+ }},
+ {1,
+ %TermNode{
+ id: "root.1",
+ kind: :number,
+ open?: false,
+ children: [],
+ content: [
+ %DisplayElement{text: "2", color: "text-code-1"},
+ %DisplayElement{text: ",", color: "text-code-2"}
+ ],
+ expanded_before: [],
+ expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}]
+ }},
+ {2,
+ %TermNode{
+ id: "root.2",
+ kind: :number,
+ open?: false,
+ children: [],
+ content: [%DisplayElement{text: "3", color: "text-code-1"}],
+ expanded_before: [],
+ expanded_after: []
+ }}
],
content: [%DisplayElement{text: "[...]", color: "text-code-2"}],
expanded_before: [%DisplayElement{text: "[", color: "text-code-2"}],
@@ -157,10 +176,11 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
expected = %TermNode{
id: "root",
kind: :list,
+ open?: true,
children: [],
content: [%DisplayElement{text: "[]", color: "text-code-2"}],
- expanded_before: nil,
- expanded_after: nil
+ expanded_before: [%DisplayElement{text: "[", color: "text-code-2"}],
+ expanded_after: [%DisplayElement{text: "]", color: "text-code-2"}]
}
assert TermParser.term_to_display_tree(term) == expected
@@ -172,10 +192,11 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
expected = %TermNode{
id: "root",
kind: :regex,
+ open?: true,
children: [],
content: [%DisplayElement{text: "~r/hello/", color: "text-code-2"}],
- expanded_before: nil,
- expanded_after: nil
+ expanded_before: [],
+ expanded_after: []
}
assert TermParser.term_to_display_tree(term) == expected
@@ -188,10 +209,11 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
expected = %TermNode{
id: "root",
kind: :atom,
+ open?: true,
children: [],
content: [%DisplayElement{text: inspect(term), color: "text-code-3"}],
- expanded_before: nil,
- expanded_after: nil
+ expanded_before: [],
+ expanded_after: []
}
assert TermParser.term_to_display_tree(term) == expected
@@ -204,10 +226,11 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
expected = %TermNode{
id: "root",
kind: :map,
+ open?: true,
children: [],
content: [%DisplayElement{text: "%{}", color: "text-code-2"}],
- expanded_before: nil,
- expanded_after: nil
+ expanded_before: [%DisplayElement{text: "%{", color: "text-code-2"}],
+ expanded_after: [%DisplayElement{text: "}", color: "text-code-2"}]
}
assert TermParser.term_to_display_tree(term) == expected
@@ -221,10 +244,12 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
expected = %TermNode{
id: "root",
kind: :struct,
+ open?: true,
children: [
- %TermNode{
+ field1: %TermNode{
id: "root.0",
kind: :binary,
+ open?: false,
children: [],
content: [
%DisplayElement{text: "field1:", color: "text-code-1"},
@@ -232,20 +257,27 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
%DisplayElement{text: "\"value1\"", color: "text-code-4"},
%DisplayElement{text: ",", color: "text-code-2"}
],
- expanded_before: nil,
- expanded_after: nil
+ expanded_before: [
+ %DisplayElement{text: "field1:", color: "text-code-1"},
+ %DisplayElement{text: " ", color: "text-code-2"}
+ ],
+ expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}]
},
- %TermNode{
+ field2: %TermNode{
id: "root.1",
kind: :number,
+ open?: false,
children: [],
content: [
%DisplayElement{text: "field2:", color: "text-code-1"},
%DisplayElement{text: " ", color: "text-code-2"},
%DisplayElement{text: "42", color: "text-code-1"}
],
- expanded_before: nil,
- expanded_after: nil
+ expanded_before: [
+ %DisplayElement{text: "field2:", color: "text-code-1"},
+ %DisplayElement{text: " ", color: "text-code-2"}
+ ],
+ expanded_after: []
}
],
content: [
@@ -269,10 +301,13 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
expected = %TermNode{
id: "root",
+ kind: :struct,
+ open?: true,
children: [
- %TermNode{
+ calendar: %TermNode{
id: "root.0",
kind: :atom,
+ open?: false,
children: [],
content: [
%DisplayElement{text: "calendar:", color: "text-code-1"},
@@ -280,12 +315,16 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
%DisplayElement{text: "Calendar.ISO", color: "text-code-1"},
%DisplayElement{text: ",", color: "text-code-2"}
],
- expanded_before: nil,
- expanded_after: nil
+ expanded_before: [
+ %DisplayElement{text: "calendar:", color: "text-code-1"},
+ %DisplayElement{text: " ", color: "text-code-2"}
+ ],
+ expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}]
},
- %TermNode{
+ month: %TermNode{
id: "root.1",
kind: :number,
+ open?: false,
children: [],
content: [
%DisplayElement{text: "month:", color: "text-code-1"},
@@ -293,12 +332,16 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
%DisplayElement{text: "5", color: "text-code-1"},
%DisplayElement{text: ",", color: "text-code-2"}
],
- expanded_before: nil,
- expanded_after: nil
+ expanded_before: [
+ %DisplayElement{text: "month:", color: "text-code-1"},
+ %DisplayElement{text: " ", color: "text-code-2"}
+ ],
+ expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}]
},
- %TermNode{
+ day: %TermNode{
id: "root.2",
kind: :number,
+ open?: false,
children: [],
content: [
%DisplayElement{text: "day:", color: "text-code-1"},
@@ -306,20 +349,27 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
%DisplayElement{text: "10", color: "text-code-1"},
%DisplayElement{text: ",", color: "text-code-2"}
],
- expanded_before: nil,
- expanded_after: nil
+ expanded_before: [
+ %DisplayElement{text: "day:", color: "text-code-1"},
+ %DisplayElement{text: " ", color: "text-code-2"}
+ ],
+ expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}]
},
- %TermNode{
+ year: %TermNode{
id: "root.3",
kind: :number,
+ open?: false,
children: [],
content: [
%DisplayElement{text: "year:", color: "text-code-1"},
%DisplayElement{text: " ", color: "text-code-2"},
%DisplayElement{text: "2023", color: "text-code-1"}
],
- expanded_before: nil,
- expanded_after: nil
+ expanded_before: [
+ %DisplayElement{text: "year:", color: "text-code-1"},
+ %DisplayElement{text: " ", color: "text-code-2"}
+ ],
+ expanded_after: []
}
],
content: [%DisplayElement{text: "~D[2023-05-10]", color: "text-code-2"}],
@@ -328,8 +378,7 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
%DisplayElement{text: "Date", color: "text-code-1"},
%DisplayElement{text: "{", color: "text-code-2"}
],
- expanded_after: [%DisplayElement{text: "}", color: "text-code-2"}],
- kind: :struct
+ expanded_after: [%DisplayElement{text: "}", color: "text-code-2"}]
}
assert TermParser.term_to_display_tree(term) == expected
@@ -345,45 +394,61 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
expected = %TermNode{
id: "root",
kind: :map,
+ open?: true,
children: [
- %TermNode{
- id: "root.0",
- kind: :binary,
- children: [],
- content: [
- %DisplayElement{text: "\"key1\"", color: "text-code-4"},
- %DisplayElement{text: " => ", color: "text-code-2"},
- %DisplayElement{text: "\"value1\"", color: "text-code-4"},
- %DisplayElement{text: ",", color: "text-code-2"}
- ],
- expanded_before: nil,
- expanded_after: nil
- },
- %TermNode{
- id: "root.1",
- kind: :number,
- children: [],
- content: [
- %DisplayElement{text: "\"key2\"", color: "text-code-4"},
- %DisplayElement{text: " => ", color: "text-code-2"},
- %DisplayElement{text: "42", color: "text-code-1"},
- %DisplayElement{text: ",", color: "text-code-2"}
- ],
- expanded_before: nil,
- expanded_after: nil
- },
- %TermNode{
- id: "root.2",
- kind: :atom,
- children: [],
- content: [
- %DisplayElement{text: "\"key3\"", color: "text-code-4"},
- %DisplayElement{text: " => ", color: "text-code-2"},
- %DisplayElement{text: ":test", color: "text-code-1"}
- ],
- expanded_before: nil,
- expanded_after: nil
- }
+ {"key1",
+ %TermNode{
+ id: "root.0",
+ kind: :binary,
+ open?: false,
+ children: [],
+ content: [
+ %DisplayElement{text: "\"key1\"", color: "text-code-4"},
+ %DisplayElement{text: " => ", color: "text-code-2"},
+ %DisplayElement{text: "\"value1\"", color: "text-code-4"},
+ %DisplayElement{text: ",", color: "text-code-2"}
+ ],
+ expanded_before: [
+ %DisplayElement{text: "\"key1\"", color: "text-code-4"},
+ %DisplayElement{text: " => ", color: "text-code-2"}
+ ],
+ expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}]
+ }},
+ {"key2",
+ %TermNode{
+ id: "root.1",
+ kind: :number,
+ open?: false,
+ children: [],
+ content: [
+ %DisplayElement{text: "\"key2\"", color: "text-code-4"},
+ %DisplayElement{text: " => ", color: "text-code-2"},
+ %DisplayElement{text: "42", color: "text-code-1"},
+ %DisplayElement{text: ",", color: "text-code-2"}
+ ],
+ expanded_before: [
+ %DisplayElement{text: "\"key2\"", color: "text-code-4"},
+ %DisplayElement{text: " => ", color: "text-code-2"}
+ ],
+ expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}]
+ }},
+ {"key3",
+ %TermNode{
+ id: "root.2",
+ kind: :atom,
+ open?: false,
+ children: [],
+ content: [
+ %DisplayElement{text: "\"key3\"", color: "text-code-4"},
+ %DisplayElement{text: " => ", color: "text-code-2"},
+ %DisplayElement{text: ":test", color: "text-code-1"}
+ ],
+ expanded_before: [
+ %DisplayElement{text: "\"key3\"", color: "text-code-4"},
+ %DisplayElement{text: " => ", color: "text-code-2"}
+ ],
+ expanded_after: []
+ }}
],
content: [%DisplayElement{text: "%{...}", color: "text-code-2"}],
expanded_before: [%DisplayElement{text: "%{", color: "text-code-2"}],
@@ -399,35 +464,48 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
Date.new(2025, 7, 8) => "Date"
}
+ {{:ok, date}, cid} = {Date.new(2025, 7, 8), %Phoenix.LiveComponent.CID{cid: 1}}
+
expected = %TermNode{
id: "root",
kind: :map,
+ open?: true,
children: [
- %TermNode{
- id: "root.0",
- kind: :binary,
- children: [],
- content: [
- %DisplayElement{text: "{:ok, ~D[2025-07-08]}", color: "text-code-2"},
- %DisplayElement{text: " => ", color: "text-code-2"},
- %DisplayElement{text: "\"Date\"", color: "text-code-4"},
- %DisplayElement{text: ",", color: "text-code-2"}
- ],
- expanded_before: nil,
- expanded_after: nil
- },
- %TermNode{
- id: "root.1",
- kind: :binary,
- children: [],
- content: [
- %DisplayElement{text: "%Phoenix.LiveComponent.CID{cid: 1}", color: "text-code-1"},
- %DisplayElement{text: " => ", color: "text-code-2"},
- %DisplayElement{text: "\"CID\"", color: "text-code-4"}
- ],
- expanded_before: nil,
- expanded_after: nil
- }
+ {{:ok, date},
+ %TermNode{
+ id: "root.0",
+ kind: :binary,
+ open?: false,
+ children: [],
+ content: [
+ %DisplayElement{text: "{:ok, ~D[2025-07-08]}", color: "text-code-2"},
+ %DisplayElement{text: " => ", color: "text-code-2"},
+ %DisplayElement{text: "\"Date\"", color: "text-code-4"},
+ %DisplayElement{text: ",", color: "text-code-2"}
+ ],
+ expanded_before: [
+ %DisplayElement{text: "{:ok, ~D[2025-07-08]}", color: "text-code-2"},
+ %DisplayElement{text: " => ", color: "text-code-2"}
+ ],
+ expanded_after: [%DisplayElement{text: ",", color: "text-code-2"}]
+ }},
+ {cid,
+ %TermNode{
+ id: "root.1",
+ kind: :binary,
+ open?: false,
+ children: [],
+ content: [
+ %DisplayElement{text: "%Phoenix.LiveComponent.CID{cid: 1}", color: "text-code-1"},
+ %DisplayElement{text: " => ", color: "text-code-2"},
+ %DisplayElement{text: "\"CID\"", color: "text-code-4"}
+ ],
+ expanded_before: [
+ %DisplayElement{text: "%Phoenix.LiveComponent.CID{cid: 1}", color: "text-code-1"},
+ %DisplayElement{text: " => ", color: "text-code-2"}
+ ],
+ expanded_after: []
+ }}
],
content: [%DisplayElement{text: "%{...}", color: "text-code-2"}],
expanded_before: [%DisplayElement{text: "%{", color: "text-code-2"}],
@@ -471,4 +549,252 @@ defmodule LiveDebugger.App.Utils.TermParserTest do
"%{pid: :erlang.list_to_pid(~c\"<0.123.0>\")}"
end
end
+
+ describe "update_by_id/3" do
+ test "updates root node" do
+ term_node = Fakes.term_node(id: "root", open?: true)
+
+ assert {:ok, %TermNode{open?: false}} =
+ TermParser.update_by_id(term_node, "root", &close_term_node/1)
+ end
+
+ test "updates child node" do
+ child = Fakes.term_node(id: "root.0", open?: true)
+ term_node = Fakes.term_node(id: "root", open?: true, children: [{0, child}])
+
+ assert {:ok, %TermNode{open?: true, children: [{0, %TermNode{open?: false}}]}} =
+ TermParser.update_by_id(term_node, "root.0", &close_term_node/1)
+ end
+
+ test "returns error if the term node is not found" do
+ term_node = Fakes.term_node(id: "root", open?: true)
+
+ assert {:error, :child_not_found} =
+ TermParser.update_by_id(term_node, "root.1", &close_term_node/1)
+ end
+ end
+
+ describe "update_by_diff/2" do
+ test "doesn't update if type is equal" do
+ term = %{a: 1, b: [1, 2, 3]}
+ term_node = TermParser.term_to_display_tree(term)
+
+ diff = %Diff{type: :equal}
+
+ assert {:ok, ^term_node} = TermParser.update_by_diff(term_node, diff)
+ end
+
+ test "updates primitive values" do
+ old_term = "Old Term"
+ new_term = "New Term"
+
+ term_node = %TermNode{content: [%DisplayElement{text: "Old Term", color: "text-code-1"}]}
+
+ diff = TermDiffer.diff(old_term, new_term)
+
+ assert {:ok,
+ %TermNode{content: [%DisplayElement{text: "\"New Term\"", color: "text-code-4"}]}} =
+ TermParser.update_by_diff(term_node, diff)
+ end
+
+ test "updates nested elements in maps" do
+ old_term = %{
+ user: %{
+ name: "Alice",
+ settings: %{
+ theme: "light",
+ notifications: true
+ }
+ }
+ }
+
+ new_term = %{
+ user: %{
+ name: "Alice",
+ settings: %{
+ theme: "dark",
+ notifications: false
+ }
+ }
+ }
+
+ term_node = TermParser.term_to_display_tree(old_term)
+ diff = TermDiffer.diff(old_term, new_term)
+
+ assert {:ok, updated_node} = TermParser.update_by_diff(term_node, diff)
+
+ {_, user_node} = Enum.find(updated_node.children, fn {key, _} -> key == :user end)
+ {_, settings_node} = Enum.find(user_node.children, fn {key, _} -> key == :settings end)
+ {_, theme_node} = Enum.find(settings_node.children, fn {key, _} -> key == :theme end)
+
+ assert theme_node.content == [
+ %DisplayElement{text: "theme:", color: "text-code-1"},
+ %DisplayElement{text: " ", color: "text-code-2"},
+ %DisplayElement{text: "\"dark\"", color: "text-code-4"}
+ ]
+ end
+
+ test "handles list insertions and deletions" do
+ old_term = [1, 2, 3]
+ new_term = [0, 2, 3, 4]
+
+ term_node = TermParser.term_to_display_tree(old_term)
+ diff = TermDiffer.diff(old_term, new_term)
+
+ assert {:ok, updated_node} = TermParser.update_by_diff(term_node, diff)
+
+ assert length(updated_node.children) == 4
+
+ assert {0,
+ %TermNode{
+ content: [
+ %DisplayElement{text: "0", color: "text-code-1"},
+ %DisplayElement{text: ",", color: "text-code-2"}
+ ]
+ }} = Enum.at(updated_node.children, 0)
+
+ assert {3,
+ %TermNode{
+ content: [
+ %DisplayElement{text: "4", color: "text-code-1"}
+ ]
+ }} = Enum.at(updated_node.children, 3)
+ end
+
+ test "properly adds and removes comma suffixes" do
+ term_node1 = %{a: 1, b: 2}
+ term_node2 = %{a: 1, b: 2, c: 3}
+
+ term_node = TermParser.term_to_display_tree(term_node1)
+ diff = TermDiffer.diff(term_node1, term_node2)
+
+ assert {:ok, updated_node} = TermParser.update_by_diff(term_node, diff)
+
+ {_, b_node} = Enum.find(updated_node.children, fn {key, _} -> key == :b end)
+ assert List.last(b_node.content) == %DisplayElement{text: ",", color: "text-code-2"}
+ assert List.last(b_node.expanded_after) == %DisplayElement{text: ",", color: "text-code-2"}
+
+ term_node3 = %{a: 1, b: 2}
+ diff = TermDiffer.diff(term_node2, term_node3)
+
+ assert {:ok, final_node} = TermParser.update_by_diff(updated_node, diff)
+
+ {_, b_node} = Enum.find(final_node.children, fn {key, _} -> key == :b end)
+ refute List.last(b_node.content) == %DisplayElement{text: ",", color: "text-code-2"}
+ refute List.last(b_node.expanded_after) == %DisplayElement{text: ",", color: "text-code-2"}
+ end
+
+ test "properly opens lists and tuples within default limits" do
+ old_term = %{small: [1], large: [1, 2, 3, 4]}
+ new_term = %{small: [1, 2], large: [1, 2, 3, 4, 5]}
+
+ term_node = TermParser.term_to_display_tree(old_term)
+ diff = TermDiffer.diff(old_term, new_term)
+
+ assert {:ok, updated_node} = TermParser.update_by_diff(term_node, diff)
+
+ assert updated_node.open? == true
+
+ assert {_, %TermNode{open?: true}} =
+ Enum.find(updated_node.children, fn {key, _} -> key == :small end)
+
+ assert {_, %TermNode{open?: false}} =
+ Enum.find(updated_node.children, fn {key, _} -> key == :large end)
+ end
+
+ test "handles struct updates" do
+ old_term = %TestStruct{field1: "old", field2: 1}
+ new_term = %TestStruct{field1: "new", field2: 2}
+
+ term_node = TermParser.term_to_display_tree(old_term)
+ diff = TermDiffer.diff(old_term, new_term)
+
+ assert {:ok, updated_node} = TermParser.update_by_diff(term_node, diff)
+
+ {_, field1_node} = Enum.find(updated_node.children, fn {key, _} -> key == :field1 end)
+
+ assert field1_node.content == [
+ %DisplayElement{text: "field1:", color: "text-code-1"},
+ %DisplayElement{text: " ", color: "text-code-2"},
+ %DisplayElement{text: "\"new\"", color: "text-code-4"},
+ %DisplayElement{text: ",", color: "text-code-2"}
+ ]
+
+ {_, field2_node} = Enum.find(updated_node.children, fn {key, _} -> key == :field2 end)
+
+ assert field2_node.content == [
+ %DisplayElement{text: "field2:", color: "text-code-1"},
+ %DisplayElement{text: " ", color: "text-code-2"},
+ %DisplayElement{text: "2", color: "text-code-1"}
+ ]
+ end
+
+ test "handles map key additions and deletions" do
+ old_term = %{"a" => 1, "b" => 2}
+ new_term = %{"b" => 2, "d" => 4}
+
+ term_node = TermParser.term_to_display_tree(old_term)
+ diff = TermDiffer.diff(old_term, new_term)
+
+ assert {:ok, updated_node} = TermParser.update_by_diff(term_node, diff)
+
+ refute Enum.any?(updated_node.children, fn {key, _} -> key == "a" end)
+
+ assert {_, d_node} = Enum.find(updated_node.children, fn {key, _} -> key == "d" end)
+
+ assert d_node.content == [
+ %DisplayElement{text: "\"d\"", color: "text-code-4"},
+ %DisplayElement{text: " => ", color: "text-code-2"},
+ %DisplayElement{text: "4", color: "text-code-1"}
+ ]
+ end
+
+ test "correctly updates node ids" do
+ old_term = %{items: [1, 2], metadata: %{version: 1}}
+ new_term = %{items: [1, 2, 3], metadata: %{version: 2}}
+
+ term_node = TermParser.term_to_display_tree(old_term)
+ diff = TermDiffer.diff(old_term, new_term)
+
+ assert {:ok,
+ %TermNode{
+ id: "root",
+ children: [
+ {:items,
+ %TermNode{
+ id: "root.0",
+ children: [
+ {0, %TermNode{id: "root.0.0"}},
+ {1, %TermNode{id: "root.0.1"}},
+ {2, %TermNode{id: "root.0.2"}}
+ ]
+ }},
+ {:metadata,
+ %TermNode{
+ id: "root.1",
+ children: [
+ {:version, %TermNode{id: "root.1.0"}}
+ ]
+ }}
+ ]
+ }} = TermParser.update_by_diff(term_node, diff)
+ end
+
+ test "returns error if the term node and diff are not compatible" do
+ invalid_diff = %Diff{
+ type: :map,
+ diff: %{non_existent_key: Fakes.term_diff_primitive()}
+ }
+
+ old_term = :not_a_map
+
+ term_node = TermParser.term_to_display_tree(old_term)
+
+ assert {:error, _} = TermParser.update_by_diff(term_node, invalid_diff)
+ end
+ end
+
+ defp close_term_node(term_node) do
+ %TermNode{term_node | open?: false}
+ end
end
diff --git a/test/support/fakes.ex b/test/support/fakes.ex
index 72e23fbce..fca43523b 100644
--- a/test/support/fakes.ex
+++ b/test/support/fakes.ex
@@ -3,6 +3,25 @@ defmodule LiveDebugger.Fakes do
Fake complex structures
"""
+ def display_element(opts \\ []) do
+ %LiveDebugger.App.Utils.TermNode.DisplayElement{
+ text: Keyword.get(opts, :text, "text"),
+ color: Keyword.get(opts, :color, "text-code-1")
+ }
+ end
+
+ def term_node(opts \\ []) do
+ %LiveDebugger.App.Utils.TermNode{
+ id: Keyword.get(opts, :id, "indexed"),
+ kind: Keyword.get(opts, :kind, :atom),
+ content: Keyword.get(opts, :content, [display_element()]),
+ children: Keyword.get(opts, :children, []),
+ expanded_before: Keyword.get(opts, :expanded_before, []),
+ expanded_after: Keyword.get(opts, :expanded_after, []),
+ open?: Keyword.get(opts, :open?, false)
+ }
+ end
+
def term_diff_primitive(opts \\ []) do
old_value = Keyword.get(opts, :old_value, 1)
new_value = Keyword.get(opts, :new_value, 2)