Skip to main content

lib/npm/install/script_runner.ex

defmodule NPM.Install.ScriptRunner do
  alias NPM.Install.Lifecycle

  @moduledoc """
  Analyzes and validates npm scripts from package.json.
  """

  @common_scripts ~w(start test build lint dev serve clean format typecheck)

  @doc """
  Extracts scripts from package.json.
  """
  @spec extract(map()) :: map()
  def extract(%{"scripts" => scripts}) when is_map(scripts), do: scripts
  def extract(_), do: %{}

  @doc """
  Returns lifecycle scripts defined in the package.
  """
  @spec lifecycle(map()) :: map()
  def lifecycle(data) do
    scripts = extract(data)
    Map.take(scripts, Lifecycle.hook_names())
  end

  @doc """
  Returns non-lifecycle (custom) scripts.
  """
  @spec custom(map()) :: map()
  def custom(data) do
    scripts = extract(data)
    lifecycle_names = Lifecycle.hook_names()
    Map.reject(scripts, fn {k, _} -> k in lifecycle_names end)
  end

  @doc """
  Checks if a specific script exists.
  """
  @spec has_script?(map(), String.t()) :: boolean()
  def has_script?(data, name), do: Map.has_key?(extract(data), name)

  @doc """
  Detects common script patterns.
  """
  @spec detect_patterns(map()) :: [atom()]
  def detect_patterns(data) do
    scripts = extract(data)

    patterns = [
      {:has_test,
       Map.has_key?(scripts, "test") and
         scripts["test"] != "echo \"Error: no test specified\" && exit 1"},
      {:has_build, Map.has_key?(scripts, "build")},
      {:has_lint,
       Map.has_key?(scripts, "lint") or
         Enum.any?(scripts, fn {_, v} -> String.contains?(v, "eslint") end)},
      {:has_typecheck,
       Map.has_key?(scripts, "typecheck") or
         Enum.any?(scripts, fn {_, v} -> String.contains?(v, "tsc") end)},
      {:has_dev, Map.has_key?(scripts, "dev") or Map.has_key?(scripts, "start")}
    ]

    patterns |> Enum.filter(&elem(&1, 1)) |> Enum.map(&elem(&1, 0))
  end

  @doc """
  Returns all script names sorted.
  """
  @spec names(map()) :: [String.t()]
  def names(data), do: data |> extract() |> Map.keys() |> Enum.sort()

  @doc """
  Counts scripts.
  """
  @spec count(map()) :: non_neg_integer()
  def count(data), do: data |> extract() |> map_size()

  @doc """
  Returns common scripts that are missing.
  """
  @spec missing_common(map()) :: [String.t()]
  def missing_common(data) do
    defined = extract(data) |> Map.keys() |> MapSet.new()
    @common_scripts |> Enum.reject(&MapSet.member?(defined, &1))
  end
end