Skip to main content

lib/quickbeam/js/package_resolver.ex

defmodule QuickBEAM.JS.PackageResolver do
  @moduledoc false

  alias QuickBEAM.JS.Exports

  @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"]

  @spec relative?(String.t()) :: boolean()
  def relative?("." <> _), do: true
  def relative?("/" <> _), do: true
  def relative?(_), do: false

  @spec node_builtin?(String.t()) :: boolean()
  def node_builtin?("node:" <> _), do: true
  def node_builtin?(name), do: name in @node_builtins

  @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

  @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

  @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

  @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

  @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) ->
        specifier
        |> Path.expand(from_dir)
        |> try_resolve(opts)

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

  @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)

    target_label
    |> Path.relative_to(importer_dir)
    |> ensure_relative_prefix()
  end

  @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

  @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 ensure_relative_prefix("./" <> _ = path), do: path
  defp ensure_relative_prefix("../" <> _ = path), do: path
  defp ensure_relative_prefix(path), do: "./" <> path

  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
      opts
      |> Keyword.put(:subpath, subpath || ".")
      |> then(&resolve_entry(package_dir, &1))
    end
  end

  defp resolve_from_pkg(pkg, package_dir, subpath, conditions, extensions) do
    with :error <- resolve_via_exports(pkg, package_dir, subpath, conditions),
         :error <- resolve_via_fields(pkg, package_dir, subpath, conditions, extensions) do
      try_resolve(Path.join(package_dir, "index"), extensions: extensions)
    end
  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, subpath, _conditions, _extensions)
       when subpath != "." do
    :error
  end

  defp resolve_via_fields(pkg, package_dir, ".", conditions, extensions) do
    fields =
      if "browser" in conditions, do: ["browser", "module", "main"], else: ["module", "main"]

    Enum.find_value(fields, :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 resolve_field_target(package_dir, target, extensions) do
    full = expand_target(package_dir, target)

    case try_resolve(full, extensions: extensions) do
      {:ok, _} = ok -> ok
      :error -> nil
    end
  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

  defp read_package_json(path) do
    with {:ok, content} <- File.read(path),
         {:ok, package} <- Jason.decode(content) do
      {:ok, package}
    else
      _ -> :error
    end
  end
end