Skip to main content

lib/npm/install/rebuild.ex

defmodule NPM.Install.Rebuild do
  @moduledoc """
  Detects packages needing native rebuilds.

  Identifies packages with install scripts, binding.gyp files,
  or other indicators that they contain native addons.
  """

  @native_indicators ["binding.gyp", "CMakeLists.txt", "Makefile"]

  @doc """
  Scans node_modules for packages with native addons.
  """
  @spec scan(String.t()) :: [map()]
  def scan(node_modules_dir \\ "node_modules") do
    case File.ls(node_modules_dir) do
      {:ok, entries} ->
        entries
        |> Enum.reject(&String.starts_with?(&1, "."))
        |> Enum.flat_map(&scan_entry(node_modules_dir, &1))
        |> Enum.sort_by(& &1.name)

      _ ->
        []
    end
  end

  @doc """
  Checks if a specific package has native components.
  """
  @spec native?(String.t(), String.t()) :: boolean()
  def native?(package_dir, name \\ "") do
    has_native_files?(package_dir) or has_install_script?(package_dir, name)
  end

  @doc """
  Returns the list of packages that need rebuild after Node.js upgrade.
  """
  @spec needs_rebuild(String.t()) :: [String.t()]
  def needs_rebuild(node_modules_dir \\ "node_modules") do
    scan(node_modules_dir)
    |> Enum.map(& &1.name)
  end

  @doc """
  Formats scan results for display.
  """
  @spec format_results([map()]) :: String.t()
  def format_results([]), do: "No native addons found."

  def format_results(packages) do
    header = "Native addons (#{length(packages)}):\n"
    body = Enum.map_join(packages, "\n", &"  #{&1.name}#{&1.reason}")
    header <> body
  end

  defp scan_entry(nm_dir, entry) do
    if String.starts_with?(entry, "@") do
      scan_scoped(nm_dir, entry)
    else
      check_native(nm_dir, entry, entry)
    end
  end

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

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

  defp check_native(parent, entry, full_name) do
    pkg_dir = Path.join(parent, entry)

    cond do
      has_native_files?(pkg_dir) ->
        [%{name: full_name, reason: "has native build files"}]

      has_install_script?(pkg_dir, full_name) ->
        [%{name: full_name, reason: "has install script"}]

      true ->
        []
    end
  end

  defp has_native_files?(pkg_dir) do
    Enum.any?(@native_indicators, &File.exists?(Path.join(pkg_dir, &1)))
  end

  defp has_install_script?(pkg_dir, _name) do
    pkg_json = Path.join(pkg_dir, "package.json")

    case File.read(pkg_json) do
      {:ok, content} ->
        data = NPM.JSON.decode!(content)
        scripts = data["scripts"] || %{}
        Map.has_key?(scripts, "install") or Map.has_key?(scripts, "postinstall")

      _ ->
        false
    end
  rescue
    _ -> false
  end
end