Skip to main content

lib/npm/workspace.ex

defmodule NPM.Workspace do
  @moduledoc """
  Workspace management for npm monorepos.

  Handles discovery and resolution of workspace packages defined
  in the root `package.json` via the `workspaces` field.
  """

  @dep_fields [
    dependencies: "dependencies",
    dev_dependencies: "devDependencies",
    optional_dependencies: "optionalDependencies"
  ]

  @type project :: %{
          dependencies: %{String.t() => String.t()},
          dev_dependencies: %{String.t() => String.t()},
          optional_dependencies: %{String.t() => String.t()},
          local_links: %{String.t() => String.t()}
        }

  @doc """
  Reads dependency groups from the root package and workspace packages.

  Registry dependencies are merged across the root `package.json` and every
  workspace `package.json`. Local `workspace:` and directory `file:`
  dependencies are returned as `:local_links` so they can be linked into the
  root `node_modules/` without registry resolution.
  """
  @spec read_all(String.t()) :: {:ok, project()} | {:error, term()}
  def read_all(root_dir \\ ".") do
    with {:ok, manifests} <- manifests(root_dir),
         {:ok, local_links} <- collect_local_links(manifests),
         {:ok, groups} <- dependency_groups(manifests, local_links) do
      {:ok, Map.put(groups, :local_links, local_links)}
    end
  end

  @doc """
  Returns the dependency map that should be resolved for install.
  """
  @spec install_dependencies(project(), keyword()) ::
          {:ok, %{String.t() => String.t()}} | {:error, term()}
  def install_dependencies(project, opts \\ []) do
    groups =
      if opts[:production] do
        [project.dependencies, project.optional_dependencies]
      else
        [project.dependencies, project.dev_dependencies, project.optional_dependencies]
      end

    merge_dependency_maps(groups)
  end

  @doc """
  Reads root and workspace package manifests.
  """
  @spec manifests(String.t()) :: {:ok, [map()]} | {:error, term()}
  def manifests(root_dir \\ ".") do
    root_dir = Path.expand(root_dir)
    root_path = Path.join(root_dir, "package.json")

    with {:ok, root_data} <- read_manifest(root_path),
         {:ok, workspace_patterns} <- NPM.Package.JSON.read_workspaces(root_path),
         {:ok, workspace_manifests} <- read_workspace_manifests(root_dir, workspace_patterns) do
      {:ok,
       [%{path: root_path, dir: root_dir, root?: true, data: root_data} | workspace_manifests]}
    end
  end

  @doc """
  Discovers workspace packages from the root package.json.

  Reads the `workspaces` field and resolves glob patterns to actual
  package directories. Returns a list of workspace info maps.
  """
  @spec discover(String.t()) :: {:ok, [map()]} | {:error, term()}
  def discover(root_dir \\ ".") do
    pkg_path = Path.join(root_dir, "package.json")

    with {:ok, workspaces} <- NPM.Package.JSON.read_workspaces(pkg_path),
         packages <- resolve_workspaces(workspaces, root_dir) do
      {:ok, packages}
    end
  end

  @doc """
  Returns a list of workspace package names.
  """
  @spec names(String.t()) :: {:ok, [String.t()]} | {:error, term()}
  def names(root_dir \\ ".") do
    case discover(root_dir) do
      {:ok, packages} -> {:ok, Enum.map(packages, & &1.name)}
      error -> error
    end
  end

  @doc """
  Returns a dependency graph of inter-workspace dependencies.

  Finds which workspace packages depend on other workspace packages.
  """
  @spec dep_graph([map()]) :: %{String.t() => [String.t()]}
  def dep_graph(packages) do
    ws_names = MapSet.new(Enum.map(packages, & &1.name))

    Map.new(packages, fn pkg ->
      internal_deps =
        pkg.dependencies
        |> Map.keys()
        |> Enum.filter(&MapSet.member?(ws_names, &1))
        |> Enum.sort()

      {pkg.name, internal_deps}
    end)
  end

  @doc """
  Returns the topological build order for workspace packages.

  Packages with no inter-workspace dependencies come first.
  """
  @spec build_order([map()]) :: [String.t()]
  def build_order(packages) do
    graph = dep_graph(packages)
    topo_sort(graph)
  end

  @doc """
  Checks if a directory is a workspace root (has workspaces field).
  """
  @spec workspace_root?(String.t()) :: boolean()
  def workspace_root?(dir \\ ".") do
    case NPM.Package.JSON.read_workspaces(Path.join(dir, "package.json")) do
      {:ok, ws} when ws != [] -> true
      _ -> false
    end
  end

  defp resolve_workspaces(patterns, base_dir) do
    patterns
    |> NPM.Package.JSON.expand_workspaces(base_dir)
    |> Enum.flat_map(&read_workspace_package/1)
  end

  defp read_workspace_package(ws_dir) do
    pkg_path = Path.join(ws_dir, "package.json")

    case File.read(pkg_path) do
      {:ok, content} ->
        data = NPM.JSON.decode!(content)

        [
          %{
            name: data["name"] || Path.basename(ws_dir),
            version: data["version"] || "0.0.0",
            path: ws_dir,
            dependencies: Map.merge(data["dependencies"] || %{}, data["devDependencies"] || %{})
          }
        ]

      _ ->
        []
    end
  end

  defp topo_sort(adj) do
    g = :digraph.new()

    try do
      Enum.each(adj, fn {name, _} -> :digraph.add_vertex(g, name) end)

      Enum.each(adj, fn {name, deps} ->
        Enum.each(deps, fn dep ->
          :digraph.add_vertex(g, dep)
          :digraph.add_edge(g, dep, name)
        end)
      end)

      case :digraph_utils.topsort(g) do
        false -> Map.keys(adj)
        sorted -> sorted
      end
    after
      :digraph.delete(g)
    end
  end

  defp read_workspace_manifests(root_dir, patterns) do
    manifests =
      patterns
      |> NPM.Package.JSON.expand_workspaces(root_dir)
      |> Enum.map(&Path.expand/1)
      |> Enum.uniq()
      |> Enum.sort()
      |> Enum.reduce_while({:ok, []}, fn dir, {:ok, acc} ->
        path = Path.join(dir, "package.json")

        case read_manifest(path) do
          {:ok, data} -> {:cont, {:ok, [%{path: path, dir: dir, root?: false, data: data} | acc]}}
          {:error, reason} -> {:halt, {:error, reason}}
        end
      end)

    case manifests do
      {:ok, list} -> {:ok, Enum.reverse(list)}
      error -> error
    end
  end

  defp read_manifest(path) do
    case NPM.JSON.read_file(path) do
      {:ok, data} when is_map(data) -> {:ok, data}
      {:ok, _} -> {:error, {:invalid_package_json, path}}
      {:error, :enoent} -> {:ok, %{}}
      {:error, reason} -> {:error, reason}
    end
  end

  defp collect_local_links(manifests) do
    workspace_links = workspace_package_links(manifests)

    manifests
    |> dependency_entries()
    |> Enum.reduce_while({:ok, workspace_links}, fn {manifest, name, range}, {:ok, acc} ->
      cond do
        workspace_range?(range) ->
          if Map.has_key?(acc, name) do
            {:cont, {:ok, acc}}
          else
            {:halt, {:error, {:unknown_workspace_dependency, name, manifest.path}}}
          end

        file_range?(range) ->
          case file_link(name, range, manifest.dir) do
            {:ok, {link_name, path}} -> put_local_link(acc, link_name, path)
            {:error, reason} -> {:halt, {:error, reason}}
          end

        true ->
          {:cont, {:ok, acc}}
      end
    end)
  end

  defp workspace_package_links(manifests) do
    manifests
    |> Enum.reject(& &1.root?)
    |> Enum.flat_map(fn manifest ->
      case package_name(manifest) do
        nil -> []
        name -> [{name, manifest.dir}]
      end
    end)
    |> Map.new()
  end

  defp package_name(%{data: data, dir: dir}) do
    case Map.get(data, "name") do
      name when is_binary(name) and name != "" -> name
      _ -> Path.basename(dir)
    end
  end

  defp dependency_entries(manifests) do
    Enum.flat_map(manifests, fn manifest ->
      Enum.flat_map(@dep_fields, fn {_key, field} ->
        manifest.data
        |> Map.get(field, %{})
        |> map_entries()
        |> Enum.map(fn {name, range} -> {manifest, name, range} end)
      end)
    end)
  end

  defp dependency_groups(manifests, local_links) do
    initial = %{dependencies: %{}, dev_dependencies: %{}, optional_dependencies: %{}}

    Enum.reduce_while(manifests, {:ok, initial}, fn manifest, {:ok, acc} ->
      case merge_manifest_dependencies(acc, manifest, local_links) do
        {:ok, updated} -> {:cont, {:ok, updated}}
        {:error, reason} -> {:halt, {:error, reason}}
      end
    end)
  end

  defp merge_manifest_dependencies(acc, manifest, local_links) do
    Enum.reduce_while(@dep_fields, {:ok, acc}, fn {key, field}, {:ok, current} ->
      deps = manifest.data |> Map.get(field, %{}) |> map_entries()

      case merge_dependency_group(Map.fetch!(current, key), deps, manifest, local_links) do
        {:ok, group} -> {:cont, {:ok, Map.put(current, key, group)}}
        {:error, reason} -> {:halt, {:error, reason}}
      end
    end)
  end

  defp merge_dependency_group(group, deps, manifest, local_links) do
    Enum.reduce_while(deps, {:ok, group}, fn {name, range}, {:ok, acc} ->
      if local_dependency?(name, range, local_links) do
        {:cont, {:ok, acc}}
      else
        merge_dependency(acc, name, range, manifest.path)
      end
    end)
  end

  defp merge_dependency_maps(groups) do
    groups
    |> Enum.reduce_while({:ok, %{}}, fn group, {:ok, acc} ->
      result =
        Enum.reduce_while(group, {:ok, acc}, fn {name, range}, {:ok, inner_acc} ->
          merge_dependency(inner_acc, name, range, "package.json")
        end)

      case result do
        {:ok, updated} -> {:cont, {:ok, updated}}
        {:error, reason} -> {:halt, {:error, reason}}
      end
    end)
  end

  defp merge_dependency(acc, name, range, path) do
    case Map.fetch(acc, name) do
      {:ok, ^range} ->
        {:cont, {:ok, acc}}

      {:ok, existing} ->
        {:halt, {:error, {:conflicting_workspace_dependency, name, existing, range, path}}}

      :error ->
        {:cont, {:ok, Map.put(acc, name, range)}}
    end
  end

  defp map_entries(map) when is_map(map) do
    Enum.flat_map(map, fn
      {name, range} when is_binary(name) and is_binary(range) -> [{name, range}]
      _ -> []
    end)
  end

  defp map_entries(_), do: []

  defp local_dependency?(name, range, local_links) do
    Map.has_key?(local_links, name) or workspace_range?(range) or file_range?(range)
  end

  defp workspace_range?("workspace:" <> _), do: true
  defp workspace_range?(_), do: false

  defp file_range?("file:" <> _), do: true
  defp file_range?(_), do: false

  defp file_link(name, "file:" <> path, base_dir) do
    package_dir = Path.expand(path, base_dir)
    package_json = Path.join(package_dir, "package.json")

    case NPM.JSON.read_file(package_json) do
      {:ok, data} when is_map(data) -> {:ok, {name, package_dir}}
      {:ok, _} -> {:error, {:invalid_file_dependency, name, package_json}}
      {:error, :enoent} -> {:error, {:missing_file_dependency, name, package_dir}}
      {:error, reason} -> {:error, reason}
    end
  end

  defp put_local_link(acc, name, path) do
    case Map.fetch(acc, name) do
      {:ok, ^path} ->
        {:cont, {:ok, acc}}

      {:ok, existing} ->
        {:halt, {:error, {:conflicting_local_dependency, name, existing, path}}}

      :error ->
        {:cont, {:ok, Map.put(acc, name, path)}}
    end
  end
end