defmodule NPM.NodeVersion do @moduledoc """ Parses Node.js version pinning files (.nvmrc, .node-version, .tool-versions). """ @version_files ~w(.nvmrc .node-version) @doc """ Detects and reads the Node.js version from project files. """ @spec detect(String.t()) :: {:ok, String.t(), String.t()} | :not_found def detect(project_dir \\ ".") do case Enum.find_value(@version_files, fn file -> path = Path.join(project_dir, file) read_version_file(path, file) end) do nil -> read_tool_versions(project_dir) || :not_found result -> result end end @doc """ Parses a .nvmrc file content. """ @spec parse_nvmrc(String.t()) :: String.t() def parse_nvmrc(content) do content |> String.trim() |> strip_v_prefix() end @doc """ Parses a .tool-versions file to extract the node version. """ @spec parse_tool_versions(String.t()) :: String.t() | nil def parse_tool_versions(content) do content |> String.split("\n") |> Enum.find_value(fn line -> case String.split(String.trim(line), ~r/\s+/, parts: 2) do ["nodejs", version] -> strip_v_prefix(version) _ -> nil end end) end @doc """ Checks if a version string looks like a major-only version. """ @spec major_only?(String.t()) :: boolean() def major_only?(version) do String.match?(version, ~r/^\d+$/) end @doc """ Checks if a version string includes an alias (lts/*, etc). """ @spec alias?(String.t()) :: boolean() def alias?(version) do version = String.trim(version) String.starts_with?(version, "lts") or version in ~w(stable node current) end @doc """ Normalizes version to include all three parts. """ @spec normalize(String.t()) :: String.t() def normalize(version) do version = strip_v_prefix(version) case String.split(version, ".") do [_major] -> "#{version}.0.0" [_major, _minor] -> "#{version}.0" _ -> version end end defp strip_v_prefix("v" <> rest), do: rest defp strip_v_prefix(version), do: version defp read_version_file(path, file) do case File.read(path) do {:ok, content} -> version = parse_nvmrc(content) if version != "", do: {:ok, version, file} _ -> nil end end defp read_tool_versions(project_dir) do path = Path.join(project_dir, ".tool-versions") case File.read(path) do {:ok, content} -> case parse_tool_versions(content) do nil -> nil version -> {:ok, version, ".tool-versions"} end _ -> nil end end end