Current section

Files

Jump to
usage_rules lib mix tasks usage_rules.search_docs.ex
Raw

lib/mix/tasks/usage_rules.search_docs.ex

defmodule Mix.Tasks.UsageRules.SearchDocs do
use Mix.Task
# This will eventually be replaced with something like `mix hex.docs.search` ideally
@shortdoc "Searches hexdocs with human-readable output"
@moduledoc """
Searches hexdocs with human-readable output (markdown by default).
If no version is specified, defaults to version used in the current mix project.
If called outside of a mix project or the dependency is not used in the
current mix project, defaults to the latest version.
## Search documentation for all dependencies in the current mix project
$ mix usage_rules.search_docs "search term"
## Search documentation for specific packages
$ mix usage_rules.search_docs "search term" -p ecto -p ash
## Search documentation for specific versions
$ mix usage_rules.search_docs "search term" -p ecto@3.13.2 -p ash@3.5.26
## Control output format and pagination
$ mix usage_rules.search_docs "search term" --output json --page 2 --per-page 20
## Search across all packages on hex
$ mix usage_rules.search_docs "search term" --everywhere
## Search only in titles
$ mix usage_rules.search_docs "search term" --query-by title
## Search in specific fields
$ mix usage_rules.search_docs "search term" --query-by "doc,title,type"
"""
@switches [
package: :keep,
page: :integer,
per_page: :integer,
output: :string,
everywhere: :boolean,
query_by: :string
]
@aliases [p: :package, o: :output, e: :everywhere, q: :query_by]
require Logger
@impl true
def run(args) do
{:ok, _} = Application.ensure_all_started(:req)
{opts, args} = OptionParser.parse!(args, strict: @switches, aliases: @aliases)
opts = Keyword.put(opts, :mix_project, !!Mix.Project.get())
opts =
if opts[:package] do
Keyword.put(opts, :package, Keyword.get_values(opts, :package))
else
opts
end
term =
case args do
["-" <> _ | _] -> raise_bad_args!()
[] -> raise_bad_args!()
[term | _args] -> term
end
filter_by =
cond do
opts[:everywhere] ->
nil
opts[:package] not in [nil, []] ->
filter_from_packages(opts[:package])
!Mix.Project.config()[:app] ->
nil
true ->
filter_from_mix_lock()
end
query_params =
%{
q: term,
query_by: opts[:query_by] || "doc,title",
page: opts[:page] || 1,
per_page: opts[:per_page] || 10
}
|> maybe_add_filter(filter_by)
case Req.get("https://search.hexdocs.pm/", params: query_params) do
{:ok, %{status: 200, body: body}} ->
output_format = opts[:output] || "markdown"
case output_format do
"json" ->
Mix.shell().info(Jason.encode!(body, pretty: true))
"markdown" ->
format_markdown_output(body, term, opts)
_ ->
Mix.raise("Invalid output format '#{output_format}'. Use 'json' or 'markdown'")
end
{:ok, %{status: status, body: body}} ->
Mix.raise("HTTP error #{status} - #{inspect(body)}")
{:error, reason} ->
Mix.raise("Request failed\n\n#{inspect(reason)}")
end
end
defp maybe_add_filter(query_params, nil), do: query_params
defp maybe_add_filter(query_params, filter_by), do: Map.put(query_params, :filter_by, filter_by)
defp format_markdown_output(body, term, opts) do
%{
"found" => total_found,
"hits" => hits,
"page" => current_page,
"request_params" => %{"per_page" => per_page}
} = body
current_page = current_page || 1
per_page = per_page || 10
total_pages = ceil(total_found / per_page)
# Build the full markdown content
markdown_content =
build_markdown_content(term, opts, total_found, current_page, total_pages, hits, per_page)
# Output using appropriate method
if tty?() do
IO.ANSI.Docs.print(markdown_content, "text/markdown", [])
else
Mix.shell().info(markdown_content)
end
end
defp build_markdown_content(term, opts, total_found, current_page, total_pages, hits, per_page) do
# Header with help text at the top
search_scope = if opts[:everywhere], do: " across all packages", else: ""
header = """
# Search Results for "#{term}"#{search_scope}
Found #{total_found} results (page #{current_page} of #{total_pages})
#{format_navigation_help_markdown(term, current_page, total_pages, opts)}
#{format_search_scope_info(opts)}
---
"""
# Results - reverse order in TTY for better readability
results =
hits
|> if(tty?(), do: &Enum.reverse/1, else: &Function.identity/1).()
|> Enum.with_index(1)
|> Enum.map_join("\n", fn {hit, index} ->
# Adjust index calculation for reversed results
actual_index = if tty?(), do: length(hits) - index + 1, else: index
format_search_result_markdown(hit, actual_index, current_page, per_page)
end)
docs_hint =
try do
term
|> Code.string_to_quoted!()
rescue
_ ->
nil
end
|> IEx.Introspection.decompose(__ENV__)
|> case do
:error ->
""
other ->
show? =
case other do
{{:__aliases__, _, parts}, _} -> compiled?(parts)
{:{}, [], [{:__aliases__, _, parts}, _, _]} -> compiled?(parts)
{:__aliases__, _, parts} -> compiled?(parts)
_ -> false
end
if show? do
"""
For local docs, run:
mix usage_rules.docs #{term}
"""
else
""
end
end
header <> results <> docs_hint
end
defp compiled?(parts) do
parts
|> Module.concat()
|> Code.ensure_loaded?()
end
defp format_search_result_markdown(hit, index, current_page, per_page) do
%{
"document" => %{
"title" => title,
"package" => package,
"type" => type,
"ref" => ref
},
"highlights" => highlights
} = hit
# Calculate global result number
global_index = (current_page - 1) * per_page + index
# Convert <mark> tags to ANSI orange highlighting for TTY
title_display = if tty?(), do: convert_mark_tags_to_ansi(title), else: title
result = """
## #{global_index}. #{title_display}
**Package:** #{package}
**Type:** #{type}
**Reference:** #{ref}
"""
# Show highlighted snippets
highlights_text =
highlights
# Limit to 2 highlights to avoid clutter
|> Enum.take(2)
|> Enum.map_join("\n\n", fn highlight ->
snippet = highlight["snippet"]
field = highlight["field"]
# Convert <mark> tags to ANSI orange highlighting for TTY
snippet_display = if tty?(), do: convert_mark_tags_to_ansi(snippet), else: snippet
case field do
"title" -> "**Title match:** #{snippet_display}"
"doc" -> "**Content:** #{snippet_display}"
_ -> "**#{String.capitalize(field)}:** #{snippet_display}"
end
end)
footer = """
**full docs:**
https://hexdocs.pm/#{extract_package_name(package)}/#{ref}
---
"""
result <> highlights_text <> footer
end
defp convert_mark_tags_to_ansi(text) do
text
# Orange color
|> String.replace("<mark>", "\e[38;5;208m")
# Reset color
|> String.replace("</mark>", "\e[0m")
end
defp format_search_scope_info(opts) do
cond do
opts[:everywhere] ->
"**Searching:** All packages on hex.pm"
opts[:package] not in [nil, []] ->
packages = opts[:package] |> Enum.join(", ")
"**Searching:** #{packages}"
!Mix.Project.config()[:app] ->
"**Searching:** All packages on hex.pm"
true ->
"**Searching:** Dependencies from current mix project"
end
end
defp format_navigation_help_markdown(term, current_page, total_pages, opts) do
base_cmd = build_base_command(term, opts)
navigation = []
# Previous page
navigation =
if current_page > 1 do
prev_cmd = "#{base_cmd} --page #{current_page - 1}"
navigation ++ ["⬅️ **Previous page:** `#{prev_cmd}`"]
else
navigation
end
# Next page
navigation =
if current_page < total_pages do
next_cmd = "#{base_cmd} --page #{current_page + 1}"
navigation ++ ["- **Next page:** `#{next_cmd}`"]
else
navigation
end
# Jump to specific page
navigation =
if total_pages > 1 do
jump_cmd = "#{base_cmd} --page <PAGE_NUMBER>"
navigation ++ ["- **Jump to page:** `#{jump_cmd}`"]
else
navigation
end
if navigation != [] do
"## Navigation\n\n" <> Enum.join(navigation, "\n") <> "\n\n"
else
""
end
end
defp build_base_command(term, opts) do
base = "mix usage_rules.search_docs \"#{term}\""
# Add flags
flags = []
# Add package filters
packages = opts[:package] || []
package_args = Enum.map(packages, &"-p #{&1}")
flags = flags ++ package_args
# Add everywhere flag
flags =
if opts[:everywhere] do
flags ++ ["--everywhere"]
else
flags
end
if flags != [] do
"#{base} #{Enum.join(flags, " ")}"
else
base
end
end
defp extract_package_name(package_with_version) do
package_with_version
|> String.split("-")
|> Enum.drop(-1)
|> Enum.join("-")
end
defp filter_from_mix_lock do
Mix.Task.run("compile")
apps =
if apps_paths = Mix.Project.apps_paths() do
Enum.filter(Mix.Project.deps_apps(), &is_map_key(apps_paths, &1))
else
[Mix.Project.config()[:app]]
end
filter =
apps
|> Enum.flat_map(fn app ->
Application.load(app)
Application.spec(app, :applications)
end)
|> Enum.uniq()
|> Enum.map_join(", ", fn app ->
"#{app}-#{Application.spec(app, :vsn)}"
end)
"package:=[#{filter}]"
end
defp filter_from_packages(packages) do
filter =
packages
|> Enum.flat_map(fn package ->
case Req.get("https://hex.pm/api/packages/#{package}", []) do
{:ok, %{status: 200, body: body}} ->
["#{package}-#{get_latest_version(body)}"]
other ->
Logger.warning(
"Failed to get latest version for package #{package}: #{inspect(other)}"
)
[]
end
end)
|> Enum.join(", ")
"package:=[#{filter}]"
end
defp get_latest_version(package) do
versions =
for release <- package["releases"],
version = Version.parse!(release["version"]),
# ignore pre-releases like release candidates, etc.
version.pre == [] do
version
end
Enum.max(versions, Version)
end
@doc false
# assume we are not in a tty if we can't tell
defp tty? do
case :file.read_file_info("/dev/stdin") do
{:ok, info} ->
elem(info, 2) == :device
_ ->
false
end
rescue
_ ->
false
end
@spec raise_bad_args!() :: no_return()
defp raise_bad_args! do
Mix.raise("""
Must provide a search term. For example:
$ mix hex.docs.search "search term"
""")
end
end