Skip to main content

lib/npm/install/link.ex

defmodule NPM.Install.Link do
  @moduledoc """
  Manages local package linking via symlinks.

  Implements `npm link` functionality — creates symlinks in
  node_modules pointing to local package directories for development.
  """

  @type link_info :: %{
          name: String.t(),
          target: String.t(),
          link_path: String.t()
        }

  @doc """
  Links a local package into node_modules.
  """
  @spec link(String.t(), String.t()) :: {:ok, link_info()} | {:error, term()}
  def link(package_dir, node_modules_dir \\ "node_modules") do
    with {:ok, name} <- read_package_name(package_dir) do
      link_path = resolve_link_path(node_modules_dir, name)
      target = Path.expand(package_dir)

      File.mkdir_p!(Path.dirname(link_path))
      File.rm_rf!(link_path)

      case File.ln_s(target, link_path) do
        :ok ->
          {:ok, %{name: name, target: target, link_path: link_path}}

        {:error, reason} ->
          {:error, reason}
      end
    end
  end

  @doc """
  Unlinks a package from node_modules.
  """
  @spec unlink(String.t(), String.t()) :: :ok | {:error, term()}
  def unlink(name, node_modules_dir \\ "node_modules") do
    link_path = resolve_link_path(node_modules_dir, name)

    case File.read_link(link_path) do
      {:ok, _target} ->
        File.rm(link_path)

      {:error, _} ->
        {:error, :not_linked}
    end
  end

  @doc """
  Lists all linked packages in node_modules.
  """
  @spec list(String.t()) :: [link_info()]
  def list(node_modules_dir \\ "node_modules") do
    case File.ls(node_modules_dir) do
      {:ok, entries} ->
        entries
        |> Enum.flat_map(&find_links(node_modules_dir, &1))
        |> Enum.sort_by(& &1.name)

      _ ->
        []
    end
  end

  @doc """
  Checks if a package is linked (symlinked, not installed normally).
  """
  @spec linked?(String.t(), String.t()) :: boolean()
  def linked?(name, node_modules_dir \\ "node_modules") do
    link_path = resolve_link_path(node_modules_dir, name)

    case File.read_link(link_path) do
      {:ok, _} -> true
      _ -> false
    end
  end

  defp read_package_name(dir) do
    pkg_path = Path.join(dir, "package.json")

    case File.read(pkg_path) do
      {:ok, content} ->
        data = NPM.JSON.decode!(content)
        {:ok, data["name"] || Path.basename(dir)}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp resolve_link_path(nm_dir, name) do
    Path.join(nm_dir, name)
  end

  defp find_links(nm_dir, entry) do
    if String.starts_with?(entry, "@") do
      find_scoped_links(nm_dir, entry)
    else
      check_link(nm_dir, entry, entry)
    end
  end

  defp find_scoped_links(nm_dir, scope) do
    scope_dir = Path.join(nm_dir, scope)

    case File.ls(scope_dir) do
      {:ok, subs} -> Enum.flat_map(subs, &check_link(scope_dir, &1, "#{scope}/#{&1}"))
      _ -> []
    end
  end

  defp check_link(parent, entry, name) do
    path = Path.join(parent, entry)

    case File.read_link(path) do
      {:ok, target} -> [%{name: name, target: target, link_path: path}]
      _ -> []
    end
  end
end