Skip to main content

lib/quickbeam/js/bundler.ex

defmodule QuickBEAM.JS.Bundler do
  @moduledoc false

  alias QuickBEAM.JS.PackageResolver

  @ts_extensions [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json"]
  @resolve_opts [extensions: @ts_extensions]

  @spec bundle_file(String.t(), keyword()) :: {:ok, String.t()} | {:error, term()}
  def bundle_file(entry_path, opts \\ []) do
    entry_path = Path.expand(entry_path)
    node_modules = Keyword.get(opts, :node_modules) || find_node_modules(entry_path)
    project_root = project_root(entry_path, node_modules)
    entry_label = relative_label(entry_path, project_root)

    bundle_opts =
      opts
      |> Keyword.drop([:node_modules])
      |> Keyword.put_new(:entry, entry_label)

    case collect_modules(entry_path, project_root) do
      {:ok, files} -> OXC.bundle(files, bundle_opts)
      {:error, _} = error -> error
    end
  end

  defp collect_modules(entry_path, project_root) do
    case do_collect(entry_path, project_root, [], %{}) do
      {:ok, files, _seen} -> {:ok, Enum.reverse(files)}
      {:error, _} = error -> error
    end
  end

  @spec do_collect(String.t(), String.t(), [{String.t(), String.t()}], %{String.t() => true}) ::
          {:ok, [{String.t(), String.t()}], %{String.t() => true}} | {:error, term()}
  defp do_collect(abs_path, project_root, files, seen) do
    if Map.has_key?(seen, abs_path) do
      {:ok, files, seen}
    else
      with {:ok, source} <- File.read(abs_path),
           {:ok, rewritten, resolved_paths} <- rewrite_and_resolve(source, abs_path, project_root) do
        label = relative_label(abs_path, project_root)
        seen = Map.put(seen, abs_path, true)
        files = [{label, rewritten} | files]
        collect_deps(resolved_paths, project_root, files, seen)
      else
        {:error, reason} when is_atom(reason) -> {:error, {:file_read_error, abs_path, reason}}
        {:error, _} = error -> error
      end
    end
  end

  @spec collect_deps([String.t()], String.t(), [{String.t(), String.t()}], %{String.t() => true}) ::
          {:ok, [{String.t(), String.t()}], %{String.t() => true}} | {:error, term()}
  defp collect_deps([], _project_root, files, seen), do: {:ok, files, seen}

  defp collect_deps([path | rest], project_root, files, seen) do
    case do_collect(path, project_root, files, seen) do
      {:ok, files, seen} -> collect_deps(rest, project_root, files, seen)
      {:error, _} = error -> error
    end
  end

  defp rewrite_and_resolve(source, importer, project_root) do
    Process.put(:bundler_resolved, [])
    from_dir = Path.dirname(importer)

    result =
      OXC.rewrite_specifiers(source, Path.basename(importer), fn specifier ->
        resolve_and_track(specifier, from_dir, project_root)
      end)

    resolved_paths = Process.delete(:bundler_resolved) || []

    case result do
      {:ok, rewritten} -> {:ok, rewritten, Enum.reverse(resolved_paths)}
      {:error, errors} -> {:error, {:parse_error, importer, errors}}
    end
  catch
    {:error, _} = error ->
      Process.delete(:bundler_resolved)
      error
  end

  defp resolve_and_track(specifier, from_dir, project_root) do
    case PackageResolver.resolve(specifier, from_dir, @resolve_opts) do
      {:builtin, _} ->
        :keep

      {:ok, resolved_path} ->
        Process.put(:bundler_resolved, [resolved_path | Process.get(:bundler_resolved)])

        if PackageResolver.relative?(specifier) do
          :keep
        else
          {:rewrite, PackageResolver.relative_import_path(from_dir, resolved_path, project_root)}
        end

      :error ->
        throw({:error, {:module_not_found, specifier, "could not resolve"}})
    end
  end

  defp find_node_modules(entry_path) do
    PackageResolver.find_node_modules(Path.dirname(entry_path))
  end

  defp relative_label(path, root) do
    path
    |> Path.relative_to(root)
    |> Path.split()
    |> Enum.join("/")
  end

  defp project_root(entry_path, nil), do: Path.dirname(entry_path)

  defp project_root(entry_path, node_modules) do
    [entry_path, node_modules]
    |> Enum.map(&Path.split/1)
    |> shared_segments()
    |> Path.join()
  end

  defp shared_segments([first | rest]) do
    first
    |> Enum.with_index()
    |> Enum.take_while(fn {segment, index} ->
      Enum.all?(rest, &(Enum.at(&1, index) == segment))
    end)
    |> Enum.map(&elem(&1, 0))
  end
end