Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions lib/mix/tasks/usage_rules.list.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# SPDX-FileCopyrightText: 2025 usage_rules contributors <https://github.com/ash-project/usage_rules/graphs/contributors>
#
# SPDX-License-Identifier: MIT

defmodule Mix.Tasks.UsageRules.List do
use Mix.Task

@shortdoc "Lists usage-rules.md and sub-rules (usage-rules/*.md) for dependencies"

@moduledoc """
Prints each dependency that ships usage rules, and which sub-rule files exist.

Sub-rules are the basenames of `deps/<app>/usage-rules/*.md` (excluding `skills/` trees),
consistent with `mix usage_rules.sync`.

Two errors can be raised normally. First is if the dependency is not in the project,
then the "No dependency matching..." error will occur.
The second error is if you have more than one argument. Only one dependency may be filtered at one time.

## Examples

$ mix usage_rules.list

$ mix usage_rules.list ash
"""

@impl Mix.Task
def run(argv) do
{_opts, argv} = OptionParser.parse!(argv, strict: [])

filter = package_filter(argv)
pairs = discover_dep_pairs()

pairs =
case filter do
nil ->
pairs

f ->
found = Enum.filter(pairs, fn {dep, _} -> dep_matches?(dep, f) end)

if found == [] do
Mix.raise("No dependency matching #{inspect(f)} in this project")
else
found
end
end

infos =
pairs
|> Enum.map(fn {dep, path} -> {dep, path, package_usage_rules_info(path)} end)
|> Enum.filter(fn {_dep, _path, info} -> info.main? or info.subs != [] end)

cond do
infos == [] && filter ->
Mix.shell().info("Dependency \"#{filter}\" has no usage-rules.md or usage-rules/*.md.")

infos == [] ->
Mix.shell().info(
"No dependencies include usage rules (no usage-rules.md or usage-rules/*.md under deps/)."
)

true ->
Mix.shell().info(format_report(infos))
end

Mix.shell().info(builtin_note())
end

defp package_filter([]), do: nil

defp package_filter([one]) do
one
|> String.trim()
end

defp package_filter(_) do
Mix.raise("mix usage_rules.list accepts at most one package name")
end

defp dep_matches?(dep, filter) do
String.downcase(to_string(dep)) == String.downcase(filter)
end

defp discover_dep_pairs do
top_level_deps =
Mix.Project.get().project()[:deps] |> Enum.map(&elem(&1, 0))

umbrella_deps =
(Mix.Project.apps_paths() || [])
|> Enum.flat_map(fn {app, path} ->
Mix.Project.in_project(app, path, fn _ ->
Mix.Project.get().project()[:deps] |> Enum.map(&elem(&1, 0))
end)
end)

all_dep_names = Enum.uniq(top_level_deps ++ umbrella_deps)

# Prefer the converger over `Mix.Project.deps_paths/0`: the deps cache can be
# empty when another Mix project is still on the stack (e.g. an umbrella
# app or tests using `Mix.Project.in_project/4`), which would hide path deps.
Mix.Dep.Converger.converge(env: Mix.env(), target: Mix.target())
|> Enum.filter(fn %{app: app} -> app in all_dep_names end)
|> Enum.map(fn %{app: app, opts: opts} ->
dest = Keyword.fetch!(opts, :dest)
{app, Path.relative_to_cwd(dest)}
end)
|> Enum.sort_by(&elem(&1, 0))
end

defp package_usage_rules_info(package_path) when is_binary(package_path) do
main? = File.regular?(Path.join(package_path, "usage-rules.md"))
subs = sub_rule_basenames(package_path)
%{main?: main?, subs: subs}
end

defp sub_rule_basenames(package_path) do
dir = Path.join(package_path, "usage-rules")

case File.ls(dir) do
{:ok, entries} ->
entries
|> Enum.reject(&(&1 == "skills"))
|> Enum.filter(&String.ends_with?(&1, ".md"))
|> Enum.map(&Path.rootname/1)
|> Enum.sort()

{:error, _} ->
[]
end
end

defp format_report(infos) do
header = "Dependencies with usage rules:\n"

body =
Enum.map_join(infos, "\n", fn {dep, path, %{main?: main?, subs: subs}} ->
[
" #{inspect(dep)} (#{path})",
" usage-rules.md: " <> if(main?, do: "yes", else: "no"),
" sub-rules: " <> subs_line(subs)
]
|> Enum.join("\n")
end)

header <> body
end

defp subs_line([]), do: "(none)"
defp subs_line(subs), do: Enum.join(subs, ", ")

defp builtin_note do
"""
Built-in config atoms :elixir and :otp pull :usage_rules sub-rules \"elixir\" and \"otp\" (see mix usage_rules.sync docs).
"""
|> String.trim_trailing()
end
end
73 changes: 65 additions & 8 deletions lib/mix/tasks/usage_rules.sync.ex
Original file line number Diff line number Diff line change
Expand Up @@ -185,26 +185,48 @@ if Code.ensure_loaded?(Igniter) do
#{__MODULE__.Docs.code_sample(4)}
"""}

(link_error = validate_link_options(config[:usage_rules])) != nil ->
{:error, link_error}
(spec_error = validate_usage_rules_specs(config[:usage_rules])) != nil ->
{:error, spec_error}

true ->
:ok
end
end

defp validate_usage_rules_specs(usage_rules) do
validate_link_options(usage_rules)
end

defp validate_link_options(nil), do: nil
defp validate_link_options(:all), do: nil

defp validate_link_options({:all, opts}) when is_list(opts), do: validate_link_option(opts)
defp validate_link_options({:all, opts}) when is_list(opts) do
validate_spec_options(opts)
end

defp validate_link_options(specs) when is_list(specs) do
Enum.find_value(specs, fn
{_inner, opts} when is_list(opts) -> validate_link_option(opts)
_ -> nil
Enum.find_value(specs, fn spec ->
case spec do
{_inner, opts} when is_list(opts) ->
validate_spec_options(opts)

_ ->
case extract_regex_spec(spec) do
{_regex, opts} when is_list(opts) -> validate_spec_options(opts)
_ -> nil
end
end
end)
end

defp validate_spec_options(opts) when is_list(opts) do
cond do
(err = validate_link_option(opts)) != nil -> err
(err = validate_except_option(opts)) != nil -> err
true -> nil
end
end

defp validate_link_option(opts) do
case opts[:link] do
nil -> nil
Expand All @@ -213,6 +235,23 @@ if Code.ensure_loaded?(Igniter) do
end
end

defp validate_except_option(opts) do
case Keyword.get(opts, :except) do
nil ->
nil

list when is_list(list) ->
if Enum.all?(list, &(is_binary(&1) or is_atom(&1))) do
nil
else
"usage_rules :except must be a list of string or atom sub-rule names, got: #{inspect(list)}"
end

other ->
"usage_rules :except must be a list or omitted, got: #{inspect(other)}"
end
end

defp include_dep_sources(igniter) do
igniter
|> Igniter.include_glob("deps/*/usage-rules.md")
Expand Down Expand Up @@ -388,6 +427,7 @@ if Code.ensure_loaded?(Igniter) do

sub_rules =
find_available_sub_rules(igniter, package_path)
|> filter_sub_rules_by_except(opts)
|> Enum.map(fn sub_rule_name ->
{package_name, package_path, sub_rule_name, opts}
end)
Expand Down Expand Up @@ -445,10 +485,11 @@ if Code.ensure_loaded?(Igniter) do
case sub_rules_opt do
:all ->
find_available_sub_rules(igniter, package_path)
|> filter_sub_rules_by_except(opts)
|> Enum.map(fn sr -> {package_name, package_path, sr, opts} end)

list when is_list(list) ->
Enum.flat_map(list, fn sr ->
Enum.flat_map(filter_sub_rules_by_except(list, opts), fn sr ->
sub_path = Path.join([package_path, "usage-rules", "#{sr}.md"])

if Igniter.exists?(igniter, sub_path) do
Expand Down Expand Up @@ -489,6 +530,7 @@ if Code.ensure_loaded?(Igniter) do
:all ->
subs =
find_available_sub_rules(igniter, package_path)
|> filter_sub_rules_by_except(opts)
|> Enum.map(fn sr -> {package_name, package_path, sr, opts} end)

found = main ++ subs
Expand All @@ -505,7 +547,9 @@ if Code.ensure_loaded?(Igniter) do

list when is_list(list) ->
{subs, sub_errors} =
Enum.reduce(list, {[], []}, fn sr, {found_acc, err_acc} ->
Enum.reduce(filter_sub_rules_by_except(list, opts), {[], []}, fn sr,
{found_acc,
err_acc} ->
sub_path = Path.join([package_path, "usage-rules", "#{sr}.md"])

if Igniter.exists?(igniter, sub_path) do
Expand Down Expand Up @@ -566,6 +610,19 @@ if Code.ensure_loaded?(Igniter) do
# Sub-rule discovery
# -------------------------------------------------------------------

defp except_sub_rule_set(opts) do
case Keyword.get(opts, :except) do
nil -> MapSet.new()
list when is_list(list) -> MapSet.new(Enum.map(list, &to_string/1))
_ -> MapSet.new()
end
end

defp filter_sub_rules_by_except(names, opts) when is_list(names) do
ex = except_sub_rule_set(opts)
Enum.reject(names, &MapSet.member?(ex, to_string(&1)))
end

defp find_available_sub_rules(igniter, package_path) do
usage_rules_dir = Path.join(package_path, "usage-rules")

Expand Down
Loading
Loading