defmodule NPM.Search do
@moduledoc """
Local search functionality for installed packages.
Searches package names, descriptions, and keywords
in the installed node_modules.
"""
@type search_result :: %{
name: String.t(),
version: String.t(),
description: String.t() | nil,
keywords: [String.t()],
score: float()
}
@doc """
Searches installed packages matching a query string.
Matches against package name, description, and keywords.
Results are sorted by relevance score.
"""
@spec search(String.t(), String.t()) :: [search_result()]
def search(node_modules_dir, query) do
query_lower = String.downcase(query)
query_parts = String.split(query_lower)
case File.ls(node_modules_dir) do
{:ok, entries} ->
entries
|> Enum.flat_map(&read_package_info(node_modules_dir, &1))
|> Enum.flat_map(&score_result(&1, query_lower, query_parts))
|> Enum.sort_by(& &1.score, :desc)
_ ->
[]
end
end
@doc """
Scores a package against a search query.
Returns a score from 0.0 to 1.0 based on match quality.
"""
@spec score(map(), String.t()) :: float()
def score(pkg_info, query) do
query_lower = String.downcase(query)
name = String.downcase(pkg_info.name)
desc = String.downcase(pkg_info.description || "")
keywords = Enum.map(pkg_info.keywords, &String.downcase/1)
cond do
name == query_lower -> 1.0
String.starts_with?(name, query_lower) -> 0.8
String.contains?(name, query_lower) -> 0.6
query_lower in keywords -> 0.4
String.contains?(desc, query_lower) -> 0.2
true -> 0.0
end
end
@doc """
Filters search results by minimum score.
"""
@spec filter_by_score([search_result()], float()) :: [search_result()]
def filter_by_score(results, min_score) do
Enum.filter(results, &(&1.score >= min_score))
end
defp read_package_info(nm_dir, entry) do
if String.starts_with?(entry, "@") do
read_scoped(nm_dir, entry)
else
read_single(nm_dir, entry)
end
end
defp read_scoped(nm_dir, scope) do
scope_dir = Path.join(nm_dir, scope)
case File.ls(scope_dir) do
{:ok, subs} -> Enum.flat_map(subs, &read_single(scope_dir, &1, "#{scope}/#{&1}"))
_ -> []
end
end
defp read_single(parent, name, full_name \\ nil) do
pkg_json = Path.join([parent, name, "package.json"])
full_name = full_name || name
case File.read(pkg_json) do
{:ok, content} ->
data = NPM.JSON.decode!(content)
[
%{
name: data["name"] || full_name,
version: data["version"] || "0.0.0",
description: data["description"],
keywords: data["keywords"] || []
}
]
_ ->
[]
end
rescue
_ -> []
end
defp score_result(pkg_info, query_lower, query_parts) do
s = compute_score(pkg_info, query_lower, query_parts)
if s > 0.0, do: [Map.put(pkg_info, :score, s)], else: []
end
defp compute_score(pkg_info, query_lower, query_parts) do
name = String.downcase(pkg_info.name)
desc = String.downcase(pkg_info.description || "")
keywords = Enum.map(pkg_info.keywords, &String.downcase/1)
name_score = name_match_score(name, query_lower)
keyword_score = if Enum.any?(query_parts, &(&1 in keywords)), do: 0.4, else: 0.0
desc_score = if String.contains?(desc, query_lower), do: 0.2, else: 0.0
Enum.max([name_score, keyword_score, desc_score])
end
defp name_match_score(name, query) do
cond do
name == query -> 1.0
String.starts_with?(name, query) -> 0.8
String.contains?(name, query) -> 0.6
true -> 0.0
end
end
end