Skip to main content

lib/npm/dependency/usage_check.ex

defmodule NPM.Dependency.UsageCheck do
  @moduledoc """
  Detects unused and missing dependencies in a project.

  Scans source files for `require()` and `import` statements,
  then compares against declared dependencies.
  """

  @type check_result :: %{
          unused: [String.t()],
          missing: [String.t()]
        }

  @doc """
  Analyzes a project for unused and missing dependencies.
  """
  @spec check(String.t()) :: {:ok, check_result()} | {:error, term()}
  def check(project_dir) do
    pkg_path = Path.join(project_dir, "package.json")

    case NPM.JSON.read_file(pkg_path) do
      {:ok, data} when is_map(data) ->
        declared = extract_declared(data)
        used = scan_imports(project_dir)

        {:ok,
         %{
           unused: find_unused(declared, used),
           missing: find_missing(declared, used)
         }}

      {:ok, _} ->
        {:error, :invalid_package_json}

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

  @doc """
  Extracts package names from require/import statements in source code.
  """
  @spec extract_imports(String.t()) :: [String.t()]
  def extract_imports(source) do
    require_pattern = ~r/require\s*\(\s*['"]([^'"\.][^'"]*)['"]\s*\)/
    import_pattern = ~r/(?:import|export)\s+.*?from\s+['"]([^'"\.][^'"]*)['"]/
    dynamic_pattern = ~r/import\s*\(\s*['"]([^'"\.][^'"]*)['"]\s*\)/

    [require_pattern, import_pattern, dynamic_pattern]
    |> Enum.flat_map(&Regex.scan(&1, source))
    |> Enum.map(&List.last/1)
    |> Enum.map(&normalize_package_name/1)
    |> Enum.uniq()
    |> Enum.sort()
  end

  @doc """
  Normalizes a module specifier to a package name.

  Handles scoped packages and deep imports.
  """
  @spec normalize_package_name(String.t()) :: String.t()
  def normalize_package_name("@" <> rest) do
    case String.split(rest, "/", parts: 3) do
      [scope, name | _] -> "@#{scope}/#{name}"
      [scope] -> "@#{scope}"
    end
  end

  def normalize_package_name(name) do
    name |> String.split("/", parts: 2) |> hd()
  end

  @doc """
  Scans JS/TS source files in a directory for imports.
  """
  @spec scan_imports(String.t()) :: MapSet.t()
  def scan_imports(project_dir) do
    source_dirs = ["src", "lib", "app", "pages", "components"]

    source_dirs
    |> Enum.flat_map(fn dir ->
      full = Path.join(project_dir, dir)
      if File.dir?(full), do: scan_directory(full), else: []
    end)
    |> Enum.into(MapSet.new())
  end

  defp extract_declared(data) do
    deps = data["dependencies"] || %{}
    dev_deps = data["devDependencies"] || %{}
    Map.keys(deps) ++ Map.keys(dev_deps)
  end

  defp find_unused(declared, used) do
    declared
    |> Enum.reject(&MapSet.member?(used, &1))
    |> Enum.sort()
  end

  defp find_missing(declared, used) do
    declared_set = MapSet.new(declared)

    used
    |> MapSet.to_list()
    |> Enum.reject(&(MapSet.member?(declared_set, &1) or builtin?(&1)))
    |> Enum.sort()
  end

  @node_builtins ~w(fs path http https os url util crypto stream events buffer net child_process cluster dgram dns readline tls vm zlib assert)

  defp builtin?(name), do: name in @node_builtins or String.starts_with?(name, "node:")

  defp scan_directory(dir) do
    case File.ls(dir) do
      {:ok, entries} -> Enum.flat_map(entries, &scan_entry(dir, &1))
      _ -> []
    end
  end

  @js_extensions ~w(.js .jsx .ts .tsx .mjs .cjs)

  defp scan_entry(dir, entry) do
    path = Path.join(dir, entry)

    cond do
      File.dir?(path) -> scan_directory(path)
      Path.extname(entry) in @js_extensions -> scan_file(path)
      true -> []
    end
  end

  defp scan_file(path) do
    case File.read(path) do
      {:ok, content} -> extract_imports(content)
      _ -> []
    end
  end
end