Current section

Files

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

lib/mix/tasks/usage_rules.sync.ex

defmodule Mix.Tasks.UsageRules.Sync.Docs do
@moduledoc false
@spec short_doc() :: String.t()
def short_doc do
"Combine the package rules for the provided packages into the provided file, or list/gather all dependencies."
end
@spec example() :: String.t()
def example do
"mix usage_rules.sync rules.md ash ash_postgres phoenix"
end
@spec long_doc() :: String.t()
def long_doc do
"""
#{short_doc()}
## Options
* `--all` - Gather usage rules from all dependencies that have them
* `--list` - List all dependencies with usage rules. If a file is provided, shows status (present, missing, stale)
* `--remove` - Remove specified packages from the target file instead of adding them
## Examples
Combine specific packages:
```sh
#{example()}
```
Gather all dependencies with usage rules:
```sh
mix usage_rules.sync rules.md --all
```
List all dependencies with usage rules:
```sh
mix usage_rules.sync --list
```
Check status of dependencies against a specific file:
```sh
mix usage_rules.sync rules.md --list
```
Remove specific packages from a file:
```sh
mix usage_rules.sync rules.md ash phoenix --remove
```
"""
end
end
if Code.ensure_loaded?(Igniter) do
defmodule Mix.Tasks.UsageRules.Sync do
@shortdoc "#{__MODULE__.Docs.short_doc()}"
@moduledoc __MODULE__.Docs.long_doc()
use Igniter.Mix.Task
@impl Igniter.Mix.Task
def info(_argv, _composing_task) do
%Igniter.Mix.Task.Info{
# Groups allow for overlapping arguments for tasks by the same author
# See the generators guide for more.
group: :usage_rules,
example: __MODULE__.Docs.example(),
positional: [
file: [optional: true],
packages: [rest: true, optional: true]
],
schema: [
all: :boolean,
list: :boolean,
remove: :boolean
]
}
end
@impl Igniter.Mix.Task
def igniter(igniter) do
igniter =
if is_nil(igniter.parent) do
igniter
|> Igniter.assign(:prompt_on_git_changes?, false)
|> Igniter.assign(:quiet_on_no_changes?, true)
else
igniter
end
# Add all usage-rules.md files from deps directory to igniter
igniter = Igniter.include_glob(igniter, "deps/*/usage-rules.md")
top_level_deps =
Mix.Project.get().project()[:deps] |> Enum.map(&elem(&1, 0))
# Get all deps from both Mix.Project.deps_paths and Igniter rewrite sources
mix_deps =
Mix.Project.deps_paths()
|> Enum.filter(fn {dep, _path} ->
dep in top_level_deps
end)
|> Enum.map(fn {dep, path} ->
{dep, Path.relative_to_cwd(path)}
end)
igniter_deps = get_deps_from_igniter(igniter)
all_deps = (mix_deps ++ igniter_deps) |> Enum.uniq()
all_option = igniter.args.options[:all]
list_option = igniter.args.options[:list]
remove_option = igniter.args.options[:remove]
provided_packages = igniter.args.positional.packages
cond do
# If --remove is used with --all or --list, add error
remove_option && (all_option || list_option) ->
Igniter.add_issue(igniter, "Cannot use --remove with --all or --list options")
# If --remove is used without a file, add error
remove_option && is_nil(igniter.args.positional[:file]) ->
Igniter.add_issue(igniter, "--remove option requires a file to remove from")
# If --remove is used without packages, add error
remove_option && Enum.empty?(provided_packages) ->
Igniter.add_issue(igniter, "--remove option requires packages to remove")
# If --list or --all is given and packages list is not empty, add error
(all_option || list_option) && !Enum.empty?(provided_packages) ->
Igniter.add_issue(igniter, "Cannot specify packages when using --all or --list options")
# If no packages are given and neither --list nor --all nor --remove is set, add error
Enum.empty?(provided_packages) && !all_option && !list_option && !remove_option ->
add_usage_error(igniter)
# If --all is used without a file, add error
all_option && is_nil(igniter.args.positional[:file]) ->
Igniter.add_issue(igniter, "--all option requires a file to write to")
# Handle --remove option
remove_option ->
handle_remove_packages(igniter, provided_packages)
# Handle --all option
all_option ->
handle_all_option(igniter, all_deps)
# Handle --list option
list_option ->
handle_list_option(igniter, all_deps)
# Handle specific packages
true ->
handle_specific_packages(igniter, all_deps, provided_packages)
end
end
defp get_deps_from_igniter(igniter) do
if igniter.assigns[:test_mode?] do
igniter.rewrite.sources
|> Enum.filter(fn {path, _source} ->
String.match?(path, ~r|^deps/[^/]+/usage-rules\.md$|)
end)
|> Enum.map(fn {path, _source} ->
# Extract package name from deps/package_name/usage-rules.md
package_name =
path
|> String.split("/")
|> Enum.at(1)
|> String.to_atom()
# Extract package path from deps/package_name/usage-rules.md
package_path = Path.dirname(path)
{package_name, package_path}
end)
|> Enum.uniq()
else
[]
end
end
defp add_usage_error(igniter) do
Igniter.add_issue(igniter, """
Usage:
mix usage_rules.sync <file> <packages...>
Combine specific packages' usage rules into the target file
mix usage_rules.sync <file> --all
Gather usage rules from all dependencies into the target file
mix usage_rules.sync [file] --list
List packages with usage rules (optionally check status against file)
mix usage_rules.sync <file> <packages...> --remove
Remove specific packages from the target file
""")
end
defp handle_all_option(igniter, all_deps) do
all_packages_with_rules = get_packages_with_usage_rules(igniter, all_deps)
igniter
|> Igniter.add_notice(
"Found #{length(all_packages_with_rules)} dependencies with usage rules"
)
|> then(fn igniter ->
Enum.reduce(all_packages_with_rules, igniter, fn {name, _path}, acc ->
Igniter.add_notice(acc, "Including usage rules for: #{name}")
end)
end)
|> generate_usage_rules_file(all_packages_with_rules)
end
defp handle_list_option(igniter, all_deps) do
packages_with_rules = get_packages_with_usage_rules(igniter, all_deps)
if Enum.empty?(packages_with_rules) do
Igniter.add_notice(igniter, "No packages found with usage-rules.md files")
else
file_path = igniter.args.positional[:file]
if file_path do
list_packages_with_file_comparison(igniter, packages_with_rules, file_path)
else
list_packages_without_comparison(igniter, packages_with_rules)
end
end
end
defp handle_specific_packages(igniter, all_deps, provided_packages) do
packages =
all_deps
|> Enum.filter(fn {name, _path} ->
to_string(name) in provided_packages
end)
|> Enum.flat_map(fn {name, path} ->
usage_rules_path = Path.join(path, "usage-rules.md")
if Igniter.exists?(igniter, usage_rules_path) do
[{name, path}]
else
[]
end
end)
generate_usage_rules_file(igniter, packages)
end
defp handle_remove_packages(igniter, provided_packages) do
file_path = igniter.args.positional[:file]
if Igniter.exists?(igniter, file_path) do
remove_packages_from_file(igniter, file_path, provided_packages)
else
Igniter.add_issue(igniter, "File #{file_path} does not exist")
end
end
defp get_packages_with_usage_rules(igniter, all_deps) do
all_deps
|> Enum.filter(fn {_name, path} ->
usage_rules_path = Path.join(path, "usage-rules.md")
Igniter.exists?(igniter, usage_rules_path)
end)
end
defp list_packages_with_file_comparison(igniter, packages_with_rules, file_path) do
current_file_content = read_current_file_content(igniter, file_path)
Enum.reduce(packages_with_rules, igniter, fn {name, path}, acc ->
usage_rules_path = Path.join(path, "usage-rules.md")
package_rules_content =
case Rewrite.source(igniter.rewrite, usage_rules_path) do
{:ok, source} -> Rewrite.Source.get(source, :content)
{:error, _} -> File.read!(usage_rules_path)
end
status = get_package_status_in_file(name, package_rules_content, current_file_content)
colored_status = colorize_status(status)
Igniter.add_notice(acc, "#{name}: #{colored_status}")
end)
end
defp list_packages_without_comparison(igniter, packages_with_rules) do
Enum.reduce(packages_with_rules, igniter, fn {name, _path}, acc ->
Igniter.add_notice(acc, "#{name}: #{IO.ANSI.green()}has usage rules#{IO.ANSI.reset()}")
end)
end
defp read_current_file_content(igniter, file_path) do
if Igniter.exists?(igniter, file_path) do
case Rewrite.source(igniter.rewrite, file_path) do
{:ok, source} ->
Rewrite.Source.get(source, :content)
{:error, _} ->
case File.read(file_path) do
{:ok, content} -> content
{:error, _} -> ""
end
end
else
""
end
end
defp generate_usage_rules_file(igniter, packages) do
package_contents =
packages
|> Enum.map(fn {name, path} ->
usage_rules_path = Path.join(path, "usage-rules.md")
content =
case Rewrite.source(igniter.rewrite, usage_rules_path) do
{:ok, source} -> Rewrite.Source.get(source, :content)
{:error, _} -> File.read!(usage_rules_path)
end
{name,
"<-- #{name}-start -->\n" <>
"## #{name} usage\n" <>
content <>
"\n<-- #{name}-end -->"}
end)
package_rules_content = Enum.map_join(package_contents, "\n", &elem(&1, 1))
full_contents_for_new_file =
"<-- usage-rules-start -->\n" <>
package_rules_content <>
"\n<-- usage-rules-end -->"
Igniter.create_or_update_file(
igniter,
igniter.args.positional[:file],
full_contents_for_new_file,
fn source ->
current_contents = Rewrite.Source.get(source, :content)
new_content =
case String.split(current_contents, [
"<-- usage-rules-start -->\n",
"\n<-- usage-rules-end -->"
]) do
[prelude, current_packages_contents, postlude] ->
Enum.reduce(package_contents, current_packages_contents, fn {name,
package_content},
acc ->
case String.split(acc, [
"<-- #{name}-start -->\n",
"\n<-- #{name}-end -->"
]) do
[prelude, _, postlude] ->
prelude <> package_content <> postlude
_ ->
acc <> "\n" <> package_content
end
end)
|> then(fn content ->
prelude <>
"<-- usage-rules-start -->\n" <>
content <>
"\n<-- usage-rules-end -->" <>
postlude
end)
_ ->
current_contents <>
"\n<-- usage-rules-start -->\n" <>
package_rules_content <>
"\n<-- usage-rules-end -->\n"
end
Rewrite.Source.update(source, :content, new_content)
end
)
end
defp remove_packages_from_file(igniter, file_path, packages_to_remove) do
Igniter.update_file(igniter, file_path, fn source ->
current_contents = Rewrite.Source.get(source, :content)
new_content =
Enum.reduce(packages_to_remove, current_contents, fn package_name, acc ->
remove_package_from_content(acc, package_name)
end)
|> clean_empty_package_rules_section()
Rewrite.Source.update(source, :content, new_content)
end)
end
defp remove_package_from_content(content, package_name) do
package_start_marker = "<-- #{package_name}-start -->\n"
package_end_marker = "\n<-- #{package_name}-end -->"
case String.split(content, [package_start_marker, package_end_marker]) do
[prelude, _package_content, postlude] ->
# Remove the package section completely, handling newlines properly
cleaned_prelude = String.trim_trailing(prelude)
cleaned_postlude = String.trim_leading(postlude)
if cleaned_postlude == "" do
cleaned_prelude
else
cleaned_prelude <> "\n" <> cleaned_postlude
end
_ ->
# Package not found, return content unchanged
content
end
end
defp clean_empty_package_rules_section(content) do
# Handle both cases: empty section and section with only whitespace
case String.split(content, "<-- usage-rules-start -->") do
[prelude, remainder] ->
case String.split(remainder, "<-- usage-rules-end -->") do
[package_section, postlude] ->
# Check if package section is empty or only contains whitespace
if String.trim(package_section) == "" do
# Remove the entire usage-rules section if empty
cleaned_prelude = String.trim_trailing(prelude)
cleaned_postlude = String.trim_leading(postlude)
if cleaned_postlude == "" do
cleaned_prelude
else
cleaned_prelude <> "\n\n" <> cleaned_postlude
end
else
# Keep the usage-rules section
prelude <>
"<-- usage-rules-start -->" <>
package_section <> "<-- usage-rules-end -->" <> postlude
end
_ ->
# No end marker found
content
end
_ ->
# No usage-rules section found
content
end
end
defp get_package_status_in_file(name, package_rules_content, file_content) do
package_start_marker = "<-- #{name}-start -->"
package_end_marker = "<-- #{name}-end -->"
case String.split(file_content, [package_start_marker, package_end_marker]) do
[_, current_package_content, _] ->
# Package is present in file, check if content matches
expected_content = "\n## #{name} usage\n" <> package_rules_content <> "\n"
if String.trim(current_package_content) == String.trim(expected_content) do
"present"
else
"stale"
end
_ ->
# Package not found in file
"missing"
end
end
defp colorize_status("present"), do: "#{IO.ANSI.green()}present#{IO.ANSI.reset()}"
defp colorize_status("stale"), do: "#{IO.ANSI.yellow()}stale#{IO.ANSI.reset()}"
defp colorize_status("missing"), do: "#{IO.ANSI.red()}missing#{IO.ANSI.reset()}"
end
else
defmodule Mix.Tasks.UsageRules.Sync do
@shortdoc "#{__MODULE__.Docs.short_doc()} | Install `igniter` to use"
@moduledoc __MODULE__.Docs.long_doc()
use Mix.Task
@impl Mix.Task
def run(_argv) do
Mix.shell().error("""
The task 'usage_rules.sync' requires igniter. Please install igniter and try again.
For more information, see: https://hexdocs.pm/igniter/readme.html#installation
""")
exit({:shutdown, 1})
end
end
end