defmodule NPM.Tree do @moduledoc """ Formats and renders dependency trees for display. Provides `npm ls` style tree output from lockfile data. """ @doc """ Builds a tree structure from a lockfile and root dependencies. Returns a nested map representing the dependency tree. """ @spec build(map(), map()) :: map() def build(lockfile, root_deps) do Map.new(root_deps, fn {name, range} -> entry = Map.get(lockfile, name) children = if entry, do: build_children(entry, lockfile, MapSet.new([name])), else: %{} version = if entry, do: entry.version, else: "MISSING" {name, %{version: version, range: range, children: children}} end) end @doc """ Formats a dependency tree as a string. """ @spec format(map()) :: String.t() def format(tree) do tree |> Enum.sort_by(&elem(&1, 0)) |> Enum.map_join("\n", fn {name, node} -> format_node(name, node, "") end) end @doc """ Flattens a tree to a list of all packages with their depths. """ @spec flatten(map(), non_neg_integer()) :: [{String.t(), String.t(), non_neg_integer()}] def flatten(tree, depth \\ 0) do Enum.flat_map(tree, fn {name, node} -> [{name, node.version, depth} | flatten(node.children, depth + 1)] end) end @doc """ Returns the maximum depth of the tree. """ @spec max_depth(map()) :: non_neg_integer() def max_depth(tree) when map_size(tree) == 0, do: 0 def max_depth(tree) do tree |> Enum.map(fn {_name, node} -> 1 + max_depth(node.children) end) |> Enum.max() end @doc """ Counts total packages in the tree (including nested). """ @spec count(map()) :: non_neg_integer() def count(tree) do Enum.reduce(tree, 0, fn {_name, node}, acc -> acc + 1 + count(node.children) end) end @doc """ Filters the tree to only show packages matching a pattern. """ @spec filter(map(), String.t()) :: map() def filter(tree, pattern) do regex = Regex.compile!(pattern, "i") tree |> Enum.flat_map(fn {name, node} -> filtered_children = filter(node.children, pattern) if Regex.match?(regex, name) or map_size(filtered_children) > 0 do [{name, %{node | children: filtered_children}}] else [] end end) |> Map.new() end defp build_children(entry, lockfile, visited) do entry.dependencies |> Enum.flat_map(&build_child(&1, lockfile, visited)) |> Map.new() end defp build_child({dep_name, range}, lockfile, visited) do if MapSet.member?(visited, dep_name) do [{dep_name, %{version: "(circular)", range: range, children: %{}}}] else resolve_child(dep_name, range, lockfile, visited) end end defp resolve_child(dep_name, range, lockfile, visited) do case Map.get(lockfile, dep_name) do nil -> [{dep_name, %{version: "MISSING", range: range, children: %{}}}] dep_entry -> children = build_children(dep_entry, lockfile, MapSet.put(visited, dep_name)) [{dep_name, %{version: dep_entry.version, range: range, children: children}}] end end defp format_node(name, node, prefix) do line = "#{prefix}#{name}@#{node.version}" children = node.children |> Enum.sort_by(&elem(&1, 0)) |> Enum.map_join("\n", fn {child_name, child_node} -> format_node(child_name, child_node, prefix <> " ") end) if children == "", do: line, else: "#{line}\n#{children}" end end