|
| 1 | +defmodule NPM.TypesCompanion do |
| 2 | + @moduledoc """ |
| 3 | + Suggests @types/* companion packages for dependencies that need TypeScript type definitions. |
| 4 | + """ |
| 5 | + |
| 6 | + @bundled_types ~w(typescript @types/node) |
| 7 | + @has_own_types ~w(axios zod prisma date-fns) |
| 8 | + |
| 9 | + @doc """ |
| 10 | + Suggests @types/* packages for dependencies that likely need them. |
| 11 | + """ |
| 12 | + @spec suggest(map()) :: [%{package: String.t(), types_package: String.t()}] |
| 13 | + def suggest(pkg_data) do |
| 14 | + deps = Map.get(pkg_data, "dependencies", %{}) |
| 15 | + dev_deps = Map.get(pkg_data, "devDependencies", %{}) |
| 16 | + all_installed = Map.merge(deps, dev_deps) |> Map.keys() |> MapSet.new() |
| 17 | + |
| 18 | + own_types = MapSet.new(@has_own_types) |
| 19 | + |
| 20 | + deps |
| 21 | + |> Map.keys() |
| 22 | + |> Enum.reject(fn name -> scoped?(name) or MapSet.member?(own_types, name) end) |
| 23 | + |> Enum.map(fn name -> {name, types_package(name)} end) |
| 24 | + |> Enum.reject(fn {_, types} -> MapSet.member?(all_installed, types) end) |
| 25 | + |> Enum.map(fn {name, types} -> %{package: name, types_package: types} end) |
| 26 | + |> Enum.sort_by(& &1.package) |
| 27 | + end |
| 28 | + |
| 29 | + @doc """ |
| 30 | + Returns the @types/* package name for a given package. |
| 31 | + """ |
| 32 | + @spec types_package(String.t()) :: String.t() |
| 33 | + def types_package("@" <> _ = scoped) do |
| 34 | + name = scoped |> String.replace("/", "__") |> String.trim_leading("@") |
| 35 | + "@types/#{name}" |
| 36 | + end |
| 37 | + |
| 38 | + def types_package(name), do: "@types/#{name}" |
| 39 | + |
| 40 | + @doc """ |
| 41 | + Checks if a package typically ships its own types. |
| 42 | + """ |
| 43 | + @spec has_own_types?(map()) :: boolean() |
| 44 | + def has_own_types?(pkg_data) do |
| 45 | + Map.has_key?(pkg_data, "types") or |
| 46 | + Map.has_key?(pkg_data, "typings") or |
| 47 | + Map.get(pkg_data, "name", "") in @has_own_types |
| 48 | + end |
| 49 | + |
| 50 | + @doc """ |
| 51 | + Returns packages that are @types/* but whose companion is not installed. |
| 52 | + """ |
| 53 | + @spec orphaned_types(map()) :: [String.t()] |
| 54 | + def orphaned_types(pkg_data) do |
| 55 | + all_deps = |
| 56 | + Map.merge( |
| 57 | + Map.get(pkg_data, "dependencies", %{}), |
| 58 | + Map.get(pkg_data, "devDependencies", %{}) |
| 59 | + ) |
| 60 | + |
| 61 | + all_keys = Map.keys(all_deps) |> MapSet.new() |
| 62 | + |
| 63 | + all_keys |
| 64 | + |> Enum.filter(&String.starts_with?(&1, "@types/")) |
| 65 | + |> Enum.reject(fn types_pkg -> |
| 66 | + companion = types_pkg |> String.trim_leading("@types/") |> String.replace("__", "/") |
| 67 | + companion = if String.contains?(companion, "/"), do: "@#{companion}", else: companion |
| 68 | + MapSet.member?(all_keys, companion) |
| 69 | + end) |
| 70 | + |> Enum.sort() |
| 71 | + end |
| 72 | + |
| 73 | + @doc """ |
| 74 | + Returns bundled types packages. |
| 75 | + """ |
| 76 | + @spec bundled :: [String.t()] |
| 77 | + def bundled, do: @bundled_types |
| 78 | + |
| 79 | + defp scoped?("@" <> _), do: true |
| 80 | + defp scoped?(_), do: false |
| 81 | +end |
0 commit comments