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