Skip to main content

lib/npm/resolution/package_resolver.ex

defmodule NPM.Resolution.PackageResolver do
  alias NPM.Resolution.Exports

  @moduledoc """
  Resolve bare and relative import specifiers to files on disk.

  Implements the Node.js module resolution algorithm for use by
  bundlers, TypeScript tooling, and anything that needs to turn
  an import specifier into an absolute file path.

  This is the **npm domain** half of resolution — specifier parsing,
  `node_modules` traversal, `package.json` entry points, and file
  extension probing. AST rewriting stays in the consumer (e.g. OXC).

  ## Examples

      iex> NPM.Resolution.PackageResolver.split_specifier("@babel/core/lib/parse")
      {"@babel/core", "./lib/parse"}

      iex> NPM.Resolution.PackageResolver.relative?("./utils")
      true

      iex> NPM.Resolution.PackageResolver.node_builtin?("node:fs")
      true

      iex> NPM.Resolution.PackageResolver.find_node_modules("/app/src")
      "/app/node_modules"
  """

  @node_builtins ~w(
    assert async_hooks buffer child_process cluster console constants
    crypto dgram diagnostics_channel dns domain events fs http http2
    https inspector module net os path perf_hooks process punycode
    querystring readline repl stream string_decoder sys timers tls
    trace_events tty url util v8 vm wasi worker_threads zlib
  )

  @default_extensions [".js", ".mjs", ".cjs", ".json"]

  # ---------------------------------------------------------------------------
  # Specifier classification
  # ---------------------------------------------------------------------------

  @doc """
  Returns `true` for relative specifiers (`./`, `../`, or `/`).
  """
  @spec relative?(String.t()) :: boolean()
  def relative?("." <> _), do: true
  def relative?("/" <> _), do: true
  def relative?(_), do: false

  @doc """
  Returns `true` for bare (package) specifiers — anything that is
  neither relative nor a Node.js built-in.
  """
  @spec bare?(String.t()) :: boolean()
  def bare?(specifier), do: not relative?(specifier) and not node_builtin?(specifier)

  @doc """
  Returns `true` for Node.js built-in modules (`fs`, `node:path`, etc.).
  """
  @spec node_builtin?(String.t()) :: boolean()
  def node_builtin?("node:" <> _), do: true
  def node_builtin?(name), do: name in @node_builtins

  # ---------------------------------------------------------------------------
  # Specifier splitting
  # ---------------------------------------------------------------------------

  @doc """
  Split a bare specifier into `{package_name, subpath | nil}`.

  Handles scoped packages correctly:

      "lodash"                → {"lodash", nil}
      "lodash/fp"             → {"lodash", "./fp"}
      "@babel/core"           → {"@babel/core", nil}
      "@babel/core/lib/parse" → {"@babel/core", "./lib/parse"}
  """
  @spec split_specifier(String.t()) :: {String.t(), String.t() | nil}
  def split_specifier("@" <> _ = specifier) do
    case String.split(specifier, "/", parts: 3) do
      [scope, name, subpath] -> {"#{scope}/#{name}", "./#{subpath}"}
      [scope, name] -> {"#{scope}/#{name}", nil}
      _ -> {specifier, nil}
    end
  end

  def split_specifier(specifier) do
    case String.split(specifier, "/", parts: 2) do
      [name, subpath] -> {name, "./#{subpath}"}
      [name] -> {name, nil}
    end
  end

  # ---------------------------------------------------------------------------
  # node_modules traversal
  # ---------------------------------------------------------------------------

  @doc """
  Walk up from `dir` to find the nearest `node_modules` directory.

  Returns the absolute path or `nil` if none is found before the
  filesystem root.
  """
  @spec find_node_modules(String.t()) :: String.t() | nil
  def find_node_modules(dir) do
    dir = Path.expand(dir)
    candidate = Path.join(dir, "node_modules")

    cond do
      File.dir?(candidate) -> candidate
      dir == "/" -> nil
      true -> find_node_modules(Path.dirname(dir))
    end
  end

  # ---------------------------------------------------------------------------
  # File resolution (extension probing)
  # ---------------------------------------------------------------------------

  @doc """
  Resolve a file path by probing extensions and `index.*` files.

  Given a base path (without extension), tries each extension in order,
  then tries `base/index.*` for directory imports.

  ## Options

    * `:extensions` — list of extensions to probe (default: `#{inspect(@default_extensions)}`)
  """
  @spec try_resolve(String.t(), keyword()) :: {:ok, String.t()} | :error
  def try_resolve(base, opts \\ []) do
    extensions = Keyword.get(opts, :extensions, @default_extensions)

    with :error <- try_exact(base),
         :error <- try_extensions(base, extensions) do
      try_index(base, extensions)
    end
  end

  # ---------------------------------------------------------------------------
  # Package entry point resolution
  # ---------------------------------------------------------------------------

  @doc """
  Resolve the entry point of a package directory.

  Reads `package.json` and checks (in order):

    1. `exports` field (via `Exports`) with the given subpath and conditions
    2. `browser` field (when `"browser"` is in conditions and value is a string)
    3. `module` field
    4. `main` field
    5. `./index.js` fallback

  ## Options

    * `:subpath` — export subpath to resolve (default: `"."`)
    * `:conditions` — condition names for the `exports` field
      (default: `["import", "default"]`)
    * `:extensions` — extensions for file probing (default: `#{inspect(@default_extensions)}`)
  """
  @spec resolve_entry(String.t(), keyword()) :: {:ok, String.t()} | :error
  def resolve_entry(package_dir, opts \\ []) do
    subpath = Keyword.get(opts, :subpath, ".")
    conditions = Keyword.get(opts, :conditions, ["import", "default"])
    extensions = Keyword.get(opts, :extensions, @default_extensions)

    pkg_json_path = Path.join(package_dir, "package.json")

    case read_package_json(pkg_json_path) do
      {:ok, pkg} -> resolve_from_pkg(pkg, package_dir, subpath, conditions, extensions)
      :error -> try_resolve(Path.join(package_dir, "index"), extensions: extensions)
    end
  end

  @doc """
  Resolve a full import specifier to an absolute file path.

  Handles bare, relative, and built-in specifiers:

    * **Relative** (`./foo`) — resolved against `from_dir` with extension probing
    * **Package imports** (`#internal`) — resolved from the nearest package.json `imports` map
    * **Built-in** (`node:fs`) — returns `{:builtin, name}`
    * **Bare** (`lodash/fp`) — locates `node_modules`, then resolves the entry point

  ## Options

    * `:conditions` — condition names for the `exports` field
    * `:extensions` — extensions for file probing
  """
  @spec resolve(String.t(), String.t(), keyword()) ::
          {:ok, String.t()} | {:builtin, String.t()} | :error
  def resolve(specifier, from_dir, opts \\ []) do
    cond do
      node_builtin?(specifier) ->
        {:builtin, specifier}

      String.starts_with?(specifier, "#") ->
        resolve_package_import(specifier, from_dir, opts)

      relative?(specifier) ->
        base = Path.expand(specifier, from_dir)
        try_resolve(base, opts)

      true ->
        resolve_bare(specifier, from_dir, opts)
    end
  end

  # ---------------------------------------------------------------------------
  # Relative import paths
  # ---------------------------------------------------------------------------

  @doc """
  Compute a relative import path from `importer` to `target` within `project_root`.

  Both paths must be absolute. Returns a POSIX-style relative path with
  a guaranteed `./` or `../` prefix, suitable for use as an import specifier.

  ## Examples

      iex> NPM.Resolution.PackageResolver.relative_import_path(
      ...>   "/app/src/pages/home.js",
      ...>   "/app/src/utils/format.js",
      ...>   "/app"
      ...> )
      "../utils/format.js"

      iex> NPM.Resolution.PackageResolver.relative_import_path(
      ...>   "/app/src/index.js",
      ...>   "/app/src/app.js",
      ...>   "/app"
      ...> )
      "./app.js"
  """
  @spec relative_import_path(String.t(), String.t(), String.t()) :: String.t()
  def relative_import_path(importer, target, project_root) do
    importer_dir = importer |> Path.relative_to(project_root) |> Path.dirname()
    target_label = Path.relative_to(target, project_root)
    relative = Path.relative_to(target_label, importer_dir)
    ensure_relative_prefix(relative)
  end

  defp ensure_relative_prefix("./" <> _ = path), do: path
  defp ensure_relative_prefix("../" <> _ = path), do: path
  defp ensure_relative_prefix(path), do: "./" <> path

  # ---------------------------------------------------------------------------
  # Private
  # ---------------------------------------------------------------------------

  @doc "Find the nearest package root from `dir` by walking up to package.json."
  @spec nearest_package(String.t()) :: {:ok, String.t(), map()} | :error
  def nearest_package(dir) do
    dir = Path.expand(dir)
    package_json_path = Path.join(dir, "package.json")

    cond do
      File.regular?(package_json_path) ->
        with {:ok, package} <- read_package_json(package_json_path), do: {:ok, dir, package}

      dir == "/" or Path.basename(dir) == "node_modules" ->
        :error

      true ->
        nearest_package(Path.dirname(dir))
    end
  end

  @doc "Find an installed package root visible from `from_dir`."
  @spec package_root(String.t(), String.t()) :: {:ok, String.t()} | :error
  def package_root(package_name, from_dir) do
    case find_node_modules(from_dir) do
      nil ->
        :error

      node_modules ->
        package_dir = Path.join(node_modules, package_name)
        if File.dir?(package_dir), do: {:ok, package_dir}, else: :error
    end
  end

  defp resolve_package_import(specifier, from_dir, opts) do
    conditions = Keyword.get(opts, :conditions, ["import", "default"])
    extensions = Keyword.get(opts, :extensions, @default_extensions)

    with {:ok, package_dir, %{"imports" => imports}} <- nearest_package(from_dir),
         {:ok, target} <- Exports.resolve(imports, specifier, conditions) do
      package_dir
      |> expand_target(target)
      |> try_resolve(extensions: extensions)
    else
      _ -> :error
    end
  end

  defp resolve_bare(specifier, from_dir, opts) do
    {package_name, subpath} = split_specifier(specifier)

    with {:ok, package_dir} <- package_root(package_name, from_dir) do
      entry_opts =
        opts
        |> Keyword.put(:subpath, subpath || ".")

      resolve_entry(package_dir, entry_opts)
    end
  end

  defp resolve_from_pkg(pkg, package_dir, subpath, conditions, extensions) do
    with :error <- resolve_via_exports(pkg, package_dir, subpath, conditions) do
      resolve_without_exports(pkg, package_dir, subpath, conditions, extensions)
    end
  end

  defp resolve_without_exports(pkg, package_dir, ".", conditions, extensions) do
    with :error <- resolve_via_fields(pkg, package_dir, conditions, extensions) do
      try_resolve(Path.join(package_dir, "index"), extensions: extensions)
    end
  end

  defp resolve_without_exports(_pkg, package_dir, subpath, _conditions, extensions) do
    package_dir
    |> expand_target(subpath)
    |> try_resolve(extensions: extensions)
  end

  defp resolve_via_exports(pkg, package_dir, subpath, conditions) do
    case Exports.parse(pkg) do
      nil ->
        :error

      export_map ->
        case Exports.resolve(export_map, subpath, conditions) do
          {:ok, target} -> ensure_file(package_dir, target)
          :error -> :error
        end
    end
  end

  defp resolve_via_fields(pkg, package_dir, conditions, extensions) do
    conditions
    |> fields_for_conditions()
    |> Enum.find_value(:error, fn field ->
      case Map.get(pkg, field) do
        nil -> nil
        target when is_binary(target) -> resolve_field_target(package_dir, target, extensions)
        _ -> nil
      end
    end)
  end

  defp fields_for_conditions(conditions) do
    browser_fields = if "browser" in conditions, do: ["browser"], else: []

    (browser_fields ++ Enum.flat_map(conditions, &fields_for_condition/1) ++ ["module", "main"])
    |> Enum.uniq()
  end

  defp fields_for_condition("import"), do: ["module"]
  defp fields_for_condition("require"), do: ["main"]
  defp fields_for_condition("default"), do: ["main"]
  defp fields_for_condition(_), do: []

  defp resolve_field_target(package_dir, target, extensions) do
    if unsupported_field_extension?(target, extensions) do
      nil
    else
      full = expand_target(package_dir, target)

      case try_resolve(full, extensions: extensions) do
        {:ok, _} = ok -> ok
        :error -> nil
      end
    end
  end

  defp unsupported_field_extension?(target, extensions) do
    ext = Path.extname(target)
    ext != "" and ext not in extensions
  end

  defp ensure_file(package_dir, target) do
    package_dir
    |> expand_target(target)
    |> try_resolve(extensions: [""])
  end

  defp expand_target(package_dir, "./" <> rest), do: Path.join(package_dir, rest)
  defp expand_target(package_dir, target), do: Path.join(package_dir, target)

  defp try_exact(path) do
    if File.regular?(path), do: {:ok, path}, else: :error
  end

  defp try_extensions(base, extensions) do
    Enum.find_value(extensions, :error, fn ext ->
      path = base <> ext
      if File.regular?(path), do: {:ok, path}
    end)
  end

  defp try_index(base, extensions) do
    if File.dir?(base) do
      try_extensions(Path.join(base, "index"), extensions)
    else
      :error
    end
  end

  defp read_package_json(path) do
    case File.read(path) do
      {:ok, content} -> {:ok, NPM.JSON.decode!(content)}
      {:error, _} -> :error
    end
  end
end