Skip to main content

lib/npm/package/json.ex

defmodule NPM.Package.JSON do
  @moduledoc """
  Read and write `package.json` files.
  """

  @default_path "package.json"

  @doc "Read dependencies from `package.json`."
  @spec read(String.t()) :: {:ok, %{String.t() => String.t()}} | {:error, term()}
  def read(path \\ @default_path) do
    read_field(path, "dependencies")
  end

  @doc "Read all dependency groups from `package.json`."
  @spec read_all(String.t()) ::
          {:ok, %{dependencies: map(), dev_dependencies: map(), optional_dependencies: map()}}
          | {:error, term()}
  def read_all(path \\ @default_path) do
    case read_raw(path) do
      {:ok, data} ->
        {:ok,
         %{
           dependencies: Map.get(data, "dependencies", %{}),
           dev_dependencies: Map.get(data, "devDependencies", %{}),
           optional_dependencies: Map.get(data, "optionalDependencies", %{})
         }}

      {:error, :enoent} ->
        {:ok, %{dependencies: %{}, dev_dependencies: %{}, optional_dependencies: %{}}}

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

  @doc "Read scripts from `package.json`."
  @spec read_scripts(String.t()) :: {:ok, %{String.t() => String.t()}} | {:error, term()}
  def read_scripts(path \\ @default_path) do
    read_field(path, "scripts")
  end

  @doc "Read workspace patterns from `package.json`."
  @spec read_workspaces(String.t()) :: {:ok, [String.t()]} | {:error, term()}
  def read_workspaces(path \\ @default_path) do
    case read_raw(path) do
      {:ok, data} ->
        case Map.get(data, "workspaces") do
          nil -> {:ok, []}
          patterns when is_list(patterns) -> {:ok, patterns}
          %{"packages" => patterns} when is_list(patterns) -> {:ok, patterns}
          _ -> {:ok, []}
        end

      {:error, :enoent} ->
        {:ok, []}

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

  @doc """
  Expand workspace patterns to actual directories with package.json files.

  Supports glob patterns like `packages/*` and `apps/**`.
  """
  @spec expand_workspaces([String.t()], String.t()) :: [String.t()]
  def expand_workspaces(patterns, base_dir \\ ".") do
    Enum.flat_map(patterns, fn pattern ->
      Path.join(base_dir, pattern)
      |> Path.wildcard()
      |> Enum.filter(&File.exists?(Path.join(&1, "package.json")))
    end)
  end

  @doc """
  Check if a dependency range refers to a local file path.

  Supports `file:../path` and `file:./path` references.
  """
  @spec file_dep?(String.t()) :: boolean()
  def file_dep?("file:" <> _), do: true
  def file_dep?(_), do: false

  @doc """
  Resolve a file dependency path.

  Returns the absolute path for a `file:` reference.
  """
  @spec resolve_file_dep(String.t(), String.t()) :: String.t()
  def resolve_file_dep("file:" <> path, base_dir) do
    Path.expand(path, base_dir)
  end

  @doc """
  Check if a dependency range refers to a git repository.

  Supports `git+https://`, `git+ssh://`, `github:user/repo`, and `git://` URLs.
  """
  @spec git_dep?(String.t()) :: boolean()
  def git_dep?("git+" <> _), do: true
  def git_dep?("git://" <> _), do: true
  def git_dep?("github:" <> _), do: true
  def git_dep?(range), do: String.contains?(range, ".git")

  @doc """
  Check if a dependency range refers to a URL tarball.

  Supports `http://` and `https://` URLs ending in `.tgz` or `.tar.gz`.
  """
  @spec url_dep?(String.t()) :: boolean()
  def url_dep?("http://" <> _ = url), do: tarball_url?(url)
  def url_dep?("https://" <> _ = url), do: tarball_url?(url)
  def url_dep?(_), do: false

  defp tarball_url?(url) do
    String.ends_with?(url, ".tgz") or String.ends_with?(url, ".tar.gz")
  end

  @doc "Read overrides from `package.json`."
  @spec read_overrides(String.t()) :: {:ok, %{String.t() => String.t()}} | {:error, term()}
  def read_overrides(path \\ @default_path) do
    read_field(path, "overrides")
  end

  @doc "Read resolutions (Yarn-style) from `package.json`."
  @spec read_resolutions(String.t()) :: {:ok, %{String.t() => String.t()}} | {:error, term()}
  def read_resolutions(path \\ @default_path) do
    read_field(path, "resolutions")
  end

  @doc """
  Read bundleDependencies (or bundledDependencies) from `package.json`.

  Returns a list of package names that should be bundled in the tarball.
  """
  @spec read_bundle_deps(String.t()) :: {:ok, [String.t()]} | {:error, term()}
  def read_bundle_deps(path \\ @default_path) do
    case read_raw(path) do
      {:ok, data} ->
        bundle =
          Map.get(data, "bundleDependencies") ||
            Map.get(data, "bundledDependencies", [])

        case bundle do
          true -> {:ok, Map.keys(Map.get(data, "dependencies", %{}))}
          deps when is_list(deps) -> {:ok, deps}
          _ -> {:ok, []}
        end

      {:error, :enoent} ->
        {:ok, []}

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

  defp read_field(path, field) do
    case read_raw(path) do
      {:ok, data} -> {:ok, Map.get(data, field, %{})}
      {:error, :enoent} -> {:ok, %{}}
      {:error, reason} -> {:error, reason}
    end
  end

  @doc """
  Add a dependency to `package.json`, creating the file if needed.

  ## Options

    * `:dev` - when `true`, adds to `devDependencies` instead of `dependencies`
  """
  @spec add_dep(String.t(), String.t(), String.t(), keyword()) :: :ok | {:error, term()}
  def add_dep(name, range, path \\ @default_path, opts \\ []) do
    data = read_raw!(path)

    field =
      cond do
        opts[:dev] -> "devDependencies"
        opts[:optional] -> "optionalDependencies"
        true -> "dependencies"
      end

    deps = Map.get(data, field, %{})
    updated = Map.put(data, field, Map.put(deps, name, range))

    File.write(path, NPM.JSON.encode_pretty(updated))
  end

  @doc "Remove a dependency from `package.json`."
  @spec remove_dep(String.t(), String.t()) :: :ok | {:error, term()}
  def remove_dep(name, path \\ @default_path) do
    data = read_raw!(path)

    field =
      ["dependencies", "devDependencies", "optionalDependencies"]
      |> Enum.find(fn field ->
        data |> Map.get(field, %{}) |> Map.has_key?(name)
      end)

    if field do
      deps = Map.get(data, field, %{})
      updated = Map.put(data, field, Map.delete(deps, name))
      File.write(path, NPM.JSON.encode_pretty(updated))
    else
      {:error, {:not_found, name}}
    end
  end

  defp read_raw(path), do: NPM.JSON.read_file(path)

  defp read_raw!(path) do
    case read_raw(path) do
      {:ok, data} -> data
      {:error, :enoent} -> %{}
      {:error, reason} -> raise reason
    end
  end
end