├── .tool-versions ├── test ├── templates │ ├── template.css.eex │ └── template.css.eex.license ├── test_helper.exs ├── igniter │ ├── project │ │ ├── module_test.exs │ │ ├── igniter_config_test.exs │ │ ├── formatter_test.exs │ │ ├── task_aliases_test.exs │ │ └── test_test.exs │ ├── code │ │ ├── list_test.exs │ │ ├── file_test.exs │ │ ├── keyword_test.exs │ │ ├── map_test.exs │ │ └── module_test.exs │ ├── refactors │ │ └── elixir_test.exs │ ├── libs │ │ └── ecto_test.exs │ ├── test_test.exs │ └── extensions │ │ └── phoenix_test.exs └── mix │ └── tasks │ ├── igniter.add_test.exs │ ├── igniter.remove_test.exs │ ├── igniter.phx.install_test.exs │ ├── igniter.install_test.exs │ └── igniter.upgrade_igniter_test.exs ├── logos ├── igniter-logo.png ├── igniter-logo-medium.png ├── igniter-logo-small.png ├── igniter-logo-tiny.png ├── igniter-logo.png.license ├── igniter-logo-tiny.png.license ├── igniter-logo-medium.png.license └── igniter-logo-small.png.license ├── mix.lock.license ├── .tool-versions.license ├── installer ├── mix.lock.license ├── test │ └── test_helper.exs ├── README.md ├── lib │ ├── mix │ │ └── tasks │ │ │ ├── copied_tasks.ex │ │ │ ├── local.igniter.ex │ │ │ └── igniter.init_library.ex │ └── loading.ex ├── mix.exs └── mix.lock ├── .formatter.exs ├── lib ├── mix │ ├── tasks │ │ ├── igniter.setup.ex │ │ ├── igniter.move_files.ex │ │ ├── igniter.refactor.unless_to_if_not.ex │ │ ├── igniter.add_extension.ex │ │ ├── igniter.apply_upgrades.ex │ │ ├── igniter.add.ex │ │ ├── igniter.remove.ex │ │ ├── igniter.upgrade.ex │ │ ├── igniter.update_gettext.ex │ │ ├── igniter.upgrade_igniter.ex │ │ ├── igniter.refactor.rename_function.ex │ │ ├── igniter.install.ex │ │ └── igniter.phx.install.ex │ └── task │ │ ├── args.ex │ │ └── info.ex └── igniter │ ├── util │ ├── warning.ex │ ├── debug.ex │ ├── version.ex │ ├── io.ex │ └── loading.ex │ ├── libs │ ├── swoosh.ex │ └── ecto.ex │ ├── extension.ex │ ├── rewrite │ └── dot_formatter_updater.ex │ ├── code │ ├── string.ex │ ├── tuple.ex │ └── map.ex │ ├── project │ ├── test.ex │ └── formatter.ex │ ├── upgrades │ └── igniter.ex │ ├── phoenix │ ├── single.ex │ └── generator.ex │ ├── refactors │ └── elixir.ex │ ├── scribe.ex │ ├── copied_tasks.ex │ ├── inflex.ex │ └── extensions │ └── phoenix.ex ├── .igniter.exs ├── .github ├── dependabot.yml └── workflows │ ├── elixir.yml │ └── test-projects.yml ├── config └── config.exs ├── .check.exs ├── LICENSES └── MIT.txt ├── RELEASE.md ├── .gitignore ├── usage-rules.md ├── documentation ├── configuring-igniter.md ├── upgrades.md ├── writing-generators.md └── documenting-tasks.md └── mix.exs /.tool-versions: -------------------------------------------------------------------------------- 1 | erlang 27.1.2 2 | elixir 1.18.4-otp-27 3 | pipx 1.8.0 4 | -------------------------------------------------------------------------------- /test/templates/template.css.eex: -------------------------------------------------------------------------------- 1 | .<%= @class %> { 2 | background: black 3 | } 4 | -------------------------------------------------------------------------------- /logos/igniter-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ash-project/igniter/main/logos/igniter-logo.png -------------------------------------------------------------------------------- /logos/igniter-logo-medium.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ash-project/igniter/main/logos/igniter-logo-medium.png -------------------------------------------------------------------------------- /logos/igniter-logo-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ash-project/igniter/main/logos/igniter-logo-small.png -------------------------------------------------------------------------------- /logos/igniter-logo-tiny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ash-project/igniter/main/logos/igniter-logo-tiny.png -------------------------------------------------------------------------------- /mix.lock.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 igniter contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /.tool-versions.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 igniter contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /installer/mix.lock.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 igniter contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /logos/igniter-logo.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 igniter contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /logos/igniter-logo-tiny.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 igniter contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /logos/igniter-logo-medium.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 igniter contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /logos/igniter-logo-small.png.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 igniter contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /test/templates/template.css.eex.license: -------------------------------------------------------------------------------- 1 | SPDX-FileCopyrightText: 2024 igniter contributors 2 | 3 | SPDX-License-Identifier: MIT 4 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | ExUnit.start() 6 | -------------------------------------------------------------------------------- /installer/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | ExUnit.start() 6 | -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | [ 6 | inputs: ["{mix,.formatter,.igniter}.exs", "{config,lib,test,installer}/**/*.{ex,exs}"] 7 | ] 8 | -------------------------------------------------------------------------------- /test/igniter/project/module_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Project.ModuleTest do 6 | use ExUnit.Case 7 | import Igniter.Test 8 | 9 | test "module_name_prefix/1" do 10 | assert Igniter.Project.Module.module_name_prefix(test_project()) == Test 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/mix/tasks/igniter.setup.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Igniter.Setup do 6 | @moduledoc "Creates or updates a .igniter.exs file, used to configure Igniter for end user's preferences." 7 | 8 | @shortdoc @moduledoc 9 | use Igniter.Mix.Task 10 | 11 | def igniter(igniter) do 12 | Igniter.Project.IgniterConfig.setup(igniter) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/mix/tasks/igniter.move_files.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Igniter.MoveFiles do 6 | @moduledoc "Moves any relevant files to their 'correct' location." 7 | @shortdoc @moduledoc 8 | use Igniter.Mix.Task 9 | 10 | def igniter(igniter) do 11 | Mix.shell().info("Finding all modules and determining proper locations...") 12 | Igniter.Project.Module.move_files(igniter, move_all?: true) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /test/mix/tasks/igniter.add_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Igniter.AddTest do 6 | use ExUnit.Case 7 | import Igniter.Test 8 | 9 | test "adds dependencies" do 10 | test_project() 11 | |> apply_igniter!() 12 | |> Igniter.compose_task("igniter.add", ["req"]) 13 | |> assert_has_patch("mix.exs", """ 14 | + | {:req, 15 | """) 16 | |> assert_has_task("deps.get", []) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /.igniter.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # This is a configuration file for igniter. 6 | # For option documentation, see hexdocs.pm/igniter/Igniter.Project.IgniterConfig.html 7 | # To keep it up to date, use `mix igniter.setup` 8 | 9 | [ 10 | module_location: :outside_matching_folder, 11 | source_folders: [ 12 | "lib", 13 | "test/support", 14 | "installer/lib" 15 | ], 16 | dont_move_files: [ 17 | ~r"lib/mix" 18 | ] 19 | ] 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | --- 6 | updates: 7 | - directory: / 8 | groups: 9 | dev-dependencies: 10 | dependency-type: development 11 | production-dependencies: 12 | dependency-type: production 13 | ignore: 14 | - dependency-name: git_ops 15 | package-ecosystem: mix 16 | schedule: 17 | day: thursday 18 | interval: monthly 19 | versioning-strategy: lockfile-only 20 | version: 2 21 | -------------------------------------------------------------------------------- /installer/README.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | ## mix igniter.new 9 | 10 | Provides `igniter.new` installer as an archive. 11 | 12 | To install from Hex, run: 13 | 14 | ```shell 15 | $ mix archive.install hex igniter_new 16 | ``` 17 | 18 | To build and install it locally, ensure the previous version is removed prior to installation: 19 | 20 | ```shell 21 | $ cd installer 22 | $ mix archive.uninstall igniter_new 23 | $ MIX_ENV=prod mix do archive.build + archive.install 24 | ``` 25 | -------------------------------------------------------------------------------- /test/mix/tasks/igniter.remove_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Igniter.RemoveTest do 6 | use ExUnit.Case 7 | import Igniter.Test 8 | 9 | test "removes dependencies" do 10 | test_project() 11 | |> Igniter.Project.Deps.add_dep({:req, ">= 0.0.0"}) 12 | |> apply_igniter!() 13 | |> Igniter.compose_task("igniter.remove", ["req"]) 14 | |> assert_has_patch("mix.exs", """ 15 | - | {:req, ">= 0.0.0"} 16 | """) 17 | |> assert_has_task("deps.clean", ["--unlock", "--unused"]) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /lib/igniter/util/warning.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Util.Warning do 6 | @moduledoc "Utilities for emitting well formatted warnings" 7 | def warn_with_code_sample(igniter, message, code) do 8 | Igniter.add_warning(igniter, formatted_warning(message, code)) 9 | end 10 | 11 | def formatted_warning(message, code) do 12 | formatted = 13 | Code.format_string!(code) 14 | |> IO.iodata_to_binary() 15 | |> String.split("\n") 16 | |> Enum.map_join("\n", &(" " <> &1)) 17 | 18 | """ 19 | #{message} 20 | 21 | #{formatted} 22 | """ 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /installer/lib/mix/tasks/copied_tasks.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | for {task_name, config} <- Igniter.Installer.TaskHelpers.tasks() do 6 | if !Code.ensure_loaded?(config.module) do 7 | defmodule config.module do 8 | use Mix.Task 9 | 10 | @moduledoc Igniter.Installer.TaskHelpers.long_doc(task_name) 11 | @impl true 12 | @shortdoc Igniter.Installer.TaskHelpers.short_doc(task_name) 13 | def run(argv) do 14 | Igniter.Installer.TaskHelpers.wrap_task( 15 | unquote(task_name), 16 | unquote(Macro.escape(config.mfa)), 17 | argv 18 | ) 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | import Config 6 | 7 | if Mix.env() == :dev do 8 | config :git_ops, 9 | mix_project: Igniter.MixProject, 10 | no_igniter?: true, 11 | changelog_file: "CHANGELOG.md", 12 | repository_url: "https://github.com/ash-project/igniter", 13 | # Instructs the tool to manage your mix version in your `mix.exs` file 14 | # See below for more information 15 | manage_mix_version?: true, 16 | # Instructs the tool to manage the version in your README.md 17 | # Pass in `true` to use `"README.md"` or a string to customize 18 | manage_readme_version: [ 19 | "README.md" 20 | ], 21 | version_tag_prefix: "v" 22 | end 23 | -------------------------------------------------------------------------------- /lib/mix/task/args.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Mix.Task.Args do 6 | @moduledoc """ 7 | Command line arguments parsed when running an `Igniter.Mix.Task`. 8 | 9 | These args will usually be accessed through `igniter.args` when the 10 | `c:Igniter.Mix.Task.igniter/1` callback is run. To learn more about how 11 | they are parsed, see `Igniter.Mix.Task.Info`. 12 | """ 13 | 14 | defstruct positional: %{}, options: [], argv_flags: [], argv: [] 15 | 16 | @type t :: %__MODULE__{ 17 | positional: %{atom() => term()}, 18 | options: keyword(), 19 | argv_flags: list(String.t()), 20 | argv: list(String.t()) 21 | } 22 | end 23 | -------------------------------------------------------------------------------- /lib/mix/tasks/igniter.refactor.unless_to_if_not.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Igniter.Refactor.UnlessToIfNot do 6 | use Igniter.Mix.Task 7 | 8 | @example "mix igniter.refactor.unless_to_if_not" 9 | 10 | @shortdoc "Rewrites occurrences of `unless x` to `if !x` across the project." 11 | @moduledoc """ 12 | #{@shortdoc} 13 | 14 | ## Example 15 | 16 | ```bash 17 | #{@example} 18 | ``` 19 | """ 20 | 21 | def info(_argv, _composing_task) do 22 | %Igniter.Mix.Task.Info{ 23 | group: :igniter, 24 | example: @example 25 | } 26 | end 27 | 28 | def igniter(igniter) do 29 | Igniter.Refactors.Elixir.unless_to_if_not(igniter) 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /installer/lib/mix/tasks/local.igniter.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Local.Igniter do 6 | use Mix.Task 7 | 8 | @shortdoc "Updates the Igniter project generator locally" 9 | 10 | @moduledoc """ 11 | Updates the Igniter project generator locally. 12 | 13 | $ mix local.igniter 14 | 15 | Accepts the same command line options as `archive.install hex igniter_new`. 16 | """ 17 | 18 | @impl true 19 | def run(args) do 20 | ignore_module_conflict = Code.get_compiler_option(:ignore_module_conflict) 21 | 22 | Code.put_compiler_option(:ignore_module_conflict, true) 23 | 24 | Mix.Task.run("archive.install", ["hex", "igniter_new" | args]) 25 | 26 | Code.put_compiler_option(:ignore_module_conflict, ignore_module_conflict) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /test/mix/tasks/igniter.phx.install_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Igniter.Phx.InstallTest do 6 | use ExUnit.Case 7 | import Igniter.Test 8 | 9 | test "create files" do 10 | igniter = Igniter.compose_task(test_project(), "igniter.phx.install", ["my_app"]) 11 | assert Enum.count(igniter.rewrite.sources) == 49 12 | end 13 | 14 | test "inject config" do 15 | test_project() 16 | |> Igniter.compose_task("igniter.phx.install", ["my_app"]) 17 | |> assert_has_patch("config/dev.exs", """ 18 | 22 | # Configure your database 19 | 23 | config :my_app, MyApp.Repo, 20 | 24 | username: "postgres", 21 | 25 | password: "postgres", 22 | 26 | hostname: "localhost", 23 | 27 | database: "my_app_dev", 24 | 28 | stacktrace: true, 25 | 29 | show_sensitive_data_on_connection_error: true, 26 | 30 | pool_size: 10 27 | """) 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /.check.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | [ 6 | ## all available options with default values (see `mix check` docs for description) 7 | # parallel: true, 8 | # skipped: true, 9 | 10 | ## list of tools (see `mix check` docs for defaults) 11 | tools: [ 12 | ## curated tools may be disabled (e.g. the check for compilation warnings) 13 | # {:compiler, false}, 14 | 15 | ## ...or adjusted (e.g. use one-line formatter for more compact credo output) 16 | # {:credo, "mix credo --format oneline"}, 17 | 18 | {:doctor, false}, 19 | {:reuse, command: ["pipx", "run", "reuse", "lint", "-q"]} 20 | 21 | ## custom new tools may be added (mix tasks or arbitrary commands) 22 | # {:my_mix_task, command: "mix release", env: %{"MIX_ENV" => "prod"}}, 23 | # {:my_arbitrary_tool, command: "npm test", cd: "assets"}, 24 | # {:my_arbitrary_script, command: ["my_script", "argument with spaces"], cd: "scripts"} 25 | ] 26 | ] 27 | -------------------------------------------------------------------------------- /lib/igniter/libs/swoosh.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Libs.Swoosh do 6 | @moduledoc "Codemods & utilities for working with Swoosh" 7 | 8 | @doc "Lists all project modules that call `use Swoosh.Mailer`." 9 | @spec list_mailers(Igniter.t()) :: {Igniter.t(), [module()]} 10 | def list_mailers(igniter) do 11 | Igniter.Project.Module.find_all_matching_modules(igniter, fn _mod, zipper -> 12 | move_to_mailer_use(zipper) != :error 13 | end) 14 | end 15 | 16 | @doc "Moves to the use statement in a module that matches `use Swoosh.Mailer`" 17 | @spec move_to_mailer_use(Sourceror.Zipper.t()) :: 18 | :error | {:ok, Sourceror.Zipper.t()} 19 | def move_to_mailer_use(zipper) do 20 | Igniter.Code.Function.move_to_function_call(zipper, :use, 2, fn zipper -> 21 | Igniter.Code.Function.argument_equals?( 22 | zipper, 23 | 0, 24 | Swoosh.Mailer 25 | ) 26 | end) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /lib/igniter/extension.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Extension do 6 | @moduledoc """ 7 | Alter igniter's behavior by adding new functionality. 8 | 9 | This is used to allow frameworks to modify things like 10 | the conventional location of files. 11 | """ 12 | 13 | defmacro __using__(_) do 14 | quote do 15 | @behaviour Igniter.Extension 16 | end 17 | end 18 | 19 | @doc """ 20 | Choose a proper location for any given module. 21 | 22 | Possible return values: 23 | 24 | - `{:ok, path}`: The path where the module should be located. 25 | - `:error`: It should go in the default place, or according to other extensions. 26 | - `:keep`: Keep the module in the same location, unless another extension has a place for it, or its just been created. 27 | """ 28 | @callback proper_location( 29 | Igniter.t(), 30 | module(), 31 | Keyword.t() 32 | ) :: {:ok, Path.t()} | :error 33 | end 34 | -------------------------------------------------------------------------------- /LICENSES/MIT.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 6 | associated documentation files (the "Software"), to deal in the Software without restriction, including 7 | without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the 9 | following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or substantial 12 | portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 15 | LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO 16 | EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE 18 | USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | name: CI 6 | on: 7 | push: 8 | tags: 9 | - "v*" 10 | branches: [main] 11 | pull_request: 12 | branches: [main] 13 | workflow_call: 14 | jobs: 15 | ash-ci: 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | include: 20 | - elixir-version: "1.16.3-otp-26" 21 | erlang-version: "26.0" 22 | primary: false 23 | - elixir-version: "default" 24 | erlang-version: "default" 25 | primary: true 26 | uses: ash-project/ash/.github/workflows/ash-ci.yml@main 27 | with: 28 | spark-formatter: false 29 | spark-cheat-sheets: false 30 | sobelow: false 31 | elixir-version: ${{ matrix.elixir-version }} 32 | erlang-version: ${{ matrix.erlang-version }} 33 | igniter-upgrade: false 34 | publish-docs: ${{ matrix.primary }} 35 | release: ${{ matrix.primary }} 36 | reuse: true 37 | secrets: 38 | HEX_API_KEY: ${{ secrets.HEX_API_KEY }} 39 | -------------------------------------------------------------------------------- /lib/mix/tasks/igniter.add_extension.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Igniter.AddExtension do 6 | use Igniter.Mix.Task 7 | 8 | @example "mix igniter.add_extension phoenix" 9 | 10 | @shortdoc "Adds an extension to your `.igniter.exs` configuration file." 11 | @moduledoc """ 12 | #{@shortdoc} 13 | 14 | The extension can be the module name of an extension, 15 | or the string `phoenix`, which maps to `Igniter.Extensions.Phoenix`. 16 | 17 | ## Example 18 | 19 | ```bash 20 | #{@example} 21 | ``` 22 | 23 | """ 24 | 25 | def info(_argv, _composing_task) do 26 | %Igniter.Mix.Task.Info{ 27 | group: :igniter, 28 | example: @example, 29 | positional: [:extension] 30 | } 31 | end 32 | 33 | def igniter(igniter) do 34 | extension = igniter.args.positional.extension 35 | 36 | extension = 37 | if extension == "phoenix" do 38 | Igniter.Extensions.Phoenix 39 | else 40 | Igniter.Project.Module.parse(extension) 41 | end 42 | 43 | igniter 44 | |> Igniter.Project.IgniterConfig.add_extension(extension) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/igniter/project/igniter_config_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Project.IgniterConfigTest do 6 | use ExUnit.Case 7 | import Igniter.Test 8 | 9 | describe "add_extension/2" do 10 | test "adds an extension to the list" do 11 | test_project() 12 | |> Igniter.Project.IgniterConfig.add_extension(Foobar) 13 | |> assert_has_patch(".igniter.exs", """ 14 | 13 - | extensions: [], 15 | 13 + | extensions: [{Foobar, []}], 16 | """) 17 | end 18 | end 19 | 20 | describe "dont_move_file_pattern/2" do 21 | test "adds a pattern to the list" do 22 | test_project() 23 | |> Igniter.Project.IgniterConfig.dont_move_file_pattern(~r"abc") 24 | |> assert_has_patch(".igniter.exs", """ 25 | 12 - | dont_move_files: [~r"lib/mix"], 26 | 12 + | dont_move_files: [~r/abc/, ~r"lib/mix"], 27 | """) 28 | end 29 | 30 | test "doesn't add a duplicate pattern to the list" do 31 | test_project() 32 | |> Igniter.Project.IgniterConfig.dont_move_file_pattern(~r"lib/mix") 33 | |> assert_unchanged(".igniter.exs") 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/igniter/rewrite/dot_formatter_updater.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Rewrite.DotFormatterUpdater do 6 | @moduledoc false 7 | 8 | alias Rewrite.DotFormatter 9 | 10 | @behaviour Rewrite.Hook 11 | 12 | @formatter ".formatter.exs" 13 | 14 | @impl true 15 | def handle(:new, project) do 16 | {:ok, %{project | dot_formatter: dot_formatter(project)}} 17 | end 18 | 19 | def handle({action, files}, project) when action in [:added, :updated] do 20 | if dot_formatter?(files) do 21 | {:ok, %{project | dot_formatter: dot_formatter(project)}} 22 | else 23 | :ok 24 | end 25 | end 26 | 27 | defp dot_formatter(project) do 28 | case DotFormatter.read(project, 29 | ignore_unknown_deps: true, 30 | ignore_missing_sub_formatters: true 31 | ) do 32 | {:ok, dot_formatter} -> dot_formatter 33 | {:error, _error} -> DotFormatter.default() 34 | end 35 | end 36 | 37 | defp dot_formatter?(@formatter), do: true 38 | defp dot_formatter?(files) when is_list(files), do: Enum.member?(files, @formatter) 39 | defp dot_formatter?(_files), do: false 40 | end 41 | -------------------------------------------------------------------------------- /lib/mix/tasks/igniter.apply_upgrades.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Igniter.ApplyUpgrades do 6 | use Igniter.Mix.Task 7 | 8 | @example "mix igniter.apply_upgrades package1:0.3.1:0.3.2 package2:1.2.4:1.5.9" 9 | 10 | @shortdoc "Applies the upgrade scripts for the list of package version changes provided." 11 | @moduledoc """ 12 | #{@shortdoc} 13 | 14 | This can be used to explicitly run specific upgrade scripts within a given version range for a package. 15 | This is also *required* if your call to `mix igniter.upgrade` requires an upgrade of igniter itself. 16 | 17 | ```bash 18 | #{@example} 19 | ``` 20 | 21 | ## Options 22 | 23 | * `--yes` or `-y` - Accept all changes automatically 24 | """ 25 | 26 | def info(_argv, _composing_task) do 27 | %Igniter.Mix.Task.Info{ 28 | group: :igniter, 29 | example: @example, 30 | positional: [ 31 | packages: [rest: true] 32 | ], 33 | schema: [ 34 | yes: :boolean 35 | ], 36 | aliases: [y: :yes], 37 | defaults: [yes: false] 38 | } 39 | end 40 | 41 | def igniter(igniter) do 42 | Igniter.CopiedTasks.do_apply_upgrades(igniter) 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/igniter/code/list_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Code.ListTest do 6 | alias Igniter.Code 7 | alias Sourceror.Zipper 8 | 9 | use ExUnit.Case 10 | doctest Igniter.Code.List 11 | 12 | describe "map/2" do 13 | test "applies the function to every element in the list" do 14 | {:ok, zipper} = 15 | "[1, 2, 3, 4]" 16 | |> Code.Common.parse_to_zipper!() 17 | |> Code.List.map(fn %Zipper{node: {:__block__, meta, [n]}} = zipper -> 18 | updated_node = {:__block__, meta, [n * 2]} 19 | {:ok, %Zipper{zipper | node: updated_node}} 20 | end) 21 | 22 | assert {:ok, [2, 4, 6, 8]} == 23 | zipper |> Zipper.up() |> Code.Common.expand_literal() 24 | end 25 | 26 | test "the returned zipper points to the final element" do 27 | {:ok, zipper} = 28 | "[1, 2, 3, 4]" 29 | |> Code.Common.parse_to_zipper!() 30 | |> Code.List.map(fn %Zipper{node: {:__block__, meta, [n]}} = zipper -> 31 | updated_node = {:__block__, meta, [n * 2]} 32 | {:ok, %Zipper{zipper | node: updated_node}} 33 | end) 34 | 35 | assert {:ok, 8} == Code.Common.expand_literal(zipper) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /lib/mix/tasks/igniter.add.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Igniter.Add do 6 | use Igniter.Mix.Task 7 | 8 | @example "mix igniter.add dep1 dep2" 9 | 10 | @shortdoc "Adds the provided deps to `mix.exs`" 11 | @moduledoc """ 12 | #{@shortdoc} 13 | 14 | This is only useful when you want to add a dependency without running its installer, since `igniter.install` already adds the dependency to `mix.exs`. 15 | 16 | This task also gets the dependencies after completion. 17 | 18 | ## Example 19 | 20 | ```bash 21 | #{@example} 22 | ``` 23 | """ 24 | 25 | @impl Igniter.Mix.Task 26 | def info(_argv, _composing_task) do 27 | %Igniter.Mix.Task.Info{ 28 | positional: [deps: [rest: true]], 29 | schema: [ 30 | yes: :boolean 31 | ], 32 | defaults: [ 33 | yes: false 34 | ] 35 | } 36 | end 37 | 38 | @impl Igniter.Mix.Task 39 | def igniter(igniter) do 40 | igniter.args.positional.deps 41 | |> Enum.join(",") 42 | |> String.split(",") 43 | |> Enum.reduce(igniter, fn dep, igniter -> 44 | {name, version} = Igniter.Project.Deps.determine_dep_type_and_version!(dep) 45 | Igniter.Project.Deps.add_dep(igniter, {name, version}, yes?: igniter.args.options[:yes]) 46 | end) 47 | |> Igniter.add_task("deps.get") 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /lib/mix/tasks/igniter.remove.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Igniter.Remove do 6 | use Igniter.Mix.Task 7 | 8 | @example "mix igniter.remove dep1 dep2" 9 | 10 | @shortdoc "Removes the provided deps from `mix.exs`" 11 | @moduledoc """ 12 | #{@shortdoc} 13 | 14 | This task also unlocks and cleans any unused dependencies after completion. 15 | 16 | ## Important Note 17 | 18 | Igniter does not have a concept of "uninstallers" right now. All that this task does 19 | is remove dependencies. If you still have usages of a given dependency, then you will 20 | have to clean that up yourself (and likely want to do it before removing 21 | the dependency). 22 | 23 | ## Example 24 | 25 | ```bash 26 | #{@example} 27 | ``` 28 | """ 29 | 30 | @impl Igniter.Mix.Task 31 | def info(_argv, _composing_task) do 32 | %Igniter.Mix.Task.Info{ 33 | positional: [deps: [rest: true]] 34 | } 35 | end 36 | 37 | @impl Igniter.Mix.Task 38 | def igniter(igniter) do 39 | igniter.args.positional.deps 40 | |> Enum.join(",") 41 | |> String.split(",") 42 | |> Enum.map(&String.to_atom/1) 43 | |> Enum.reduce(igniter, fn name, igniter -> 44 | Igniter.Project.Deps.remove_dep(igniter, name) 45 | end) 46 | |> Igniter.add_task("deps.clean", ["--unlock", "--unused"]) 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | # Releasing igniter 9 | 10 | This documents the method of releasing igniter. You first need access to both the hex package 11 | and the GH repo. 12 | 13 | ## `mix git_ops.release` 14 | 15 | Run `mix git_ops.release` to generate a new changelog and bump the version. 16 | 17 | This will instruct you to then run `git push --follow-tags`. You can also 18 | modify the changelog while the prompt is active and before confirming if 19 | you want to clean it up a bit. I typically format the file and adjust things 20 | to make it a bit clearer. 21 | 22 | ## `mix hex.publish` 23 | 24 | You *typically* won't need to do this, but if CI fails for some reason, or you are in a hurry, 25 | you can run `mix hex.publish` to publish the hex package. CI will fail if you publish it 26 | before CI gets to that step. This is fine. 27 | 28 | ## publishing the installer `igniter_new` 29 | 30 | The process for this is less rigorous. You manually bump the version in `installer/mix.exs`, 31 | cd into `/installer` and then run `mix hex.publish`, and that's it :) 32 | If you've published a new version of `igniter` that should affect what version the 33 | installer uses, edit `@igniter_version` module attribute in `installer/lib/mix/tasks/igniter.new.ex` 34 | to match the new requirement. 35 | -------------------------------------------------------------------------------- /lib/igniter/code/string.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Code.String do 6 | @moduledoc """ 7 | Utilities for working with strings. 8 | """ 9 | 10 | alias Sourceror.Zipper 11 | 12 | @doc "Returns true if the node represents a literal string, false otherwise." 13 | @spec string?(Zipper.t()) :: boolean() 14 | def string?(zipper) do 15 | case zipper.node do 16 | v when is_binary(v) -> 17 | true 18 | 19 | {:__block__, meta, [v]} when is_binary(v) -> 20 | is_binary(meta[:delimiter]) 21 | 22 | _ -> 23 | false 24 | end 25 | end 26 | 27 | @doc "Updates a node representing a string with the result of the given function" 28 | @spec update_string(Zipper.t(), (String.t() -> {:ok, String.t()} | :error)) :: 29 | {:ok, Zipper.t()} | :error 30 | def update_string(zipper, func) do 31 | case zipper.node do 32 | v when is_binary(v) -> 33 | with {:ok, new_str} <- func.(v) do 34 | {:ok, Zipper.replace(zipper, new_str)} 35 | end 36 | 37 | {:__block__, meta, [v]} when is_binary(v) -> 38 | if is_binary(meta[:delimiter]) do 39 | with {:ok, new_str} <- func.(v) do 40 | {:ok, Zipper.replace(zipper, {:__block__, meta, [new_str]})} 41 | end 42 | else 43 | :error 44 | end 45 | 46 | _ -> 47 | :error 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/igniter/util/debug.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Util.Debug do 6 | @moduledoc "Tools for debugging zippers." 7 | alias Sourceror.Zipper 8 | 9 | @doc "Puts the formatted code at the node of the zipper to the console" 10 | def puts_code_at_node({:ok, zipper}) do 11 | zipper 12 | |> Zipper.node() 13 | |> Sourceror.to_string() 14 | |> then(&"==code==\n{:ok, #{&1}}\n==!code==\n") 15 | |> IO.puts() 16 | 17 | {:ok, zipper} 18 | end 19 | 20 | def puts_code_at_node(:error) do 21 | IO.puts("==code==\n:error\n==!code==\n") 22 | 23 | :error 24 | end 25 | 26 | def puts_code_at_node(%Zipper{} = zipper) do 27 | zipper 28 | |> Zipper.node() 29 | |> Sourceror.to_string() 30 | |> then(&"==code==\n#{&1}\n==!code==\n") 31 | |> IO.puts() 32 | 33 | zipper 34 | end 35 | 36 | def puts_code_at_node(node) do 37 | node 38 | |> Sourceror.to_string() 39 | |> then(&"==code==\n#{&1}\n==!code==\n") 40 | |> IO.puts() 41 | 42 | node 43 | end 44 | 45 | @doc "Returns the formatted code at the node of the zipper to the console" 46 | def code_at_node(zipper) do 47 | zipper 48 | |> Zipper.node() 49 | |> Sourceror.to_string() 50 | end 51 | 52 | @doc "Puts the ast at the node of the zipper to the console" 53 | def puts_ast_at_node(%Zipper{} = zipper) do 54 | zipper 55 | |> Zipper.node() 56 | |> then(&"==ast==\n#{inspect(&1, pretty: true)}\n==!ast==\n") 57 | |> IO.puts() 58 | 59 | zipper 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/igniter/util/version.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Util.Version do 6 | @moduledoc "Utilities for working versions and version requirements" 7 | 8 | @doc """ 9 | Provides a general requirement for a given version string. 10 | 11 | For example 12 | 13 | `3.1.2` would be `~> 3.0` 14 | and 15 | `0.2.4` would be `~> 0.2` 16 | """ 17 | @spec version_string_to_general_requirement!(String.t()) :: String.t() | no_return 18 | def version_string_to_general_requirement!(version) do 19 | case version_string_to_general_requirement(version) do 20 | {:ok, requirement} -> requirement 21 | {:error, error} -> raise ArgumentError, error 22 | end 23 | end 24 | 25 | def version_string_to_general_requirement(version) do 26 | version 27 | |> pad_zeroes() 28 | |> Version.parse() 29 | |> case do 30 | {:ok, %Version{major: major, minor: minor, patch: patch, pre: pre}} when pre != [] -> 31 | {:ok, "~> #{major}.#{minor}.#{patch}-#{Enum.join(pre, ".")}"} 32 | 33 | {:ok, %Version{major: 0, minor: minor}} -> 34 | {:ok, "~> 0.#{minor}"} 35 | 36 | {:ok, %Version{major: major}} -> 37 | {:ok, "~> #{major}.0"} 38 | 39 | :error -> 40 | {:error, "invalid version string"} 41 | end 42 | end 43 | 44 | defp pad_zeroes(version) do 45 | case String.split(version, ".", trim: true) do 46 | [_major, _minor] -> version <> ".0" 47 | [_major] -> version <> ".0.0" 48 | _ -> version 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | # The directory Mix will write compiled artifacts to. 6 | /_build/ 7 | 8 | # If you run "mix test --cover", coverage assets end up here. 9 | /cover/ 10 | 11 | # The directory Mix downloads your dependencies sources to. 12 | /deps/ 13 | 14 | # Where third-party dependencies like ExDoc output generated docs. 15 | /doc/ 16 | 17 | # Ignore .fetch files in case you like to edit your project deps locally. 18 | /.fetch 19 | 20 | # If the VM crashes, it generates a dump, let's ignore it too. 21 | erl_crash.dump 22 | 23 | # Also ignore archive artifacts (built via "mix archive.build"). 24 | *.ez 25 | 26 | # Ignore package tarball (built via "mix hex.build"). 27 | igniter-*.tar 28 | 29 | # Temporary files, for example, from tests. 30 | /tmp/ 31 | 32 | # The directory Mix will write compiled artifacts to. 33 | /installer/_build/ 34 | 35 | # If you run "mix test --cover", coverage assets end up here. 36 | /installer/cover/ 37 | 38 | # The directory Mix downloads your dependencies sources to. 39 | /installer/deps/ 40 | 41 | # Where third-party dependencies like ExDoc output generated docs. 42 | /installer/doc/ 43 | 44 | # Ignore .fetch files in case you like to edit your project deps locally. 45 | /installer/.fetch 46 | 47 | /installer/priv/docs 48 | 49 | # If the VM crashes, it generates a dump, let's ignore it too. 50 | installer/erl_crash.dump 51 | 52 | /test_project 53 | 54 | # Also ignore archive artifacts (built via "mix archive.build"). 55 | *.ez 56 | 57 | # Ignore package tarball (built via "mix hex.build"). 58 | igniter-*.tar 59 | 60 | # Temporary files, for example, from tests. 61 | installer/tmp/ 62 | -------------------------------------------------------------------------------- /lib/igniter/project/test.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Project.Test do 6 | @moduledoc "Codemods and utilities for interacting with test and test support files" 7 | def ensure_test_support(igniter) do 8 | Igniter.update_elixir_file(igniter, "mix.exs", fn zipper -> 9 | with {:ok, zipper} <- Igniter.Code.Module.move_to_module_using(zipper, Mix.Project), 10 | {:ok, zipper} <- Igniter.Code.Function.move_to_def(zipper, :project, 0), 11 | {:ok, zipper} <- 12 | Igniter.Code.Common.move_right(zipper, &Igniter.Code.List.list?/1) do 13 | case Igniter.Code.Keyword.get_key(zipper, :elixirc_paths) do 14 | {:ok, zipper} -> 15 | {:ok, Sourceror.Zipper.top(zipper)} 16 | 17 | _ -> 18 | with {:ok, zipper} <- 19 | Igniter.Code.List.append_to_list( 20 | zipper, 21 | quote(do: {:elixirc_paths, elixirc_paths(Mix.env())}) 22 | ), 23 | zipper <- Sourceror.Zipper.top(zipper), 24 | {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper), 25 | zipper <- 26 | Igniter.Code.Common.add_code( 27 | zipper, 28 | "defp elixirc_paths(:test), do: elixirc_paths(:dev) ++ [\"test/support\"]" 29 | ) do 30 | {:ok, 31 | Igniter.Code.Common.add_code( 32 | zipper, 33 | "defp elixirc_paths(_), do: [\"lib\"]" 34 | )} 35 | end 36 | end 37 | end 38 | end) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /installer/mix.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.New.MixProject do 6 | use Mix.Project 7 | 8 | @version "0.5.32" 9 | @scm_url "https://github.com/ash-project/igniter" 10 | 11 | def project do 12 | [ 13 | app: :igniter_new, 14 | start_permanent: Mix.env() == :prod, 15 | version: @version, 16 | elixir: "~> 1.14", 17 | deps: deps(), 18 | package: [ 19 | maintainers: ["Zach Daniel"], 20 | licenses: ["MIT"], 21 | links: %{"GitHub" => @scm_url}, 22 | files: ~w(lib mix.exs README.md priv) 23 | ], 24 | preferred_cli_env: [docs: :docs], 25 | source_url: @scm_url, 26 | docs: docs(), 27 | aliases: aliases(), 28 | homepage_url: "https://www.ash-hq.org", 29 | description: """ 30 | Create a new mix project with igniter, and run igniter installers in one command! 31 | """ 32 | ] 33 | end 34 | 35 | def application do 36 | [ 37 | extra_applications: [:hex, :eex, :crypto, :public_key] 38 | ] 39 | end 40 | 41 | def deps do 42 | [ 43 | {:ex_doc, "~> 0.24", only: [:dev, :test], runtime: false} 44 | ] 45 | end 46 | 47 | defp docs do 48 | [ 49 | source_url_pattern: "#{@scm_url}/blob/v#{@version}/installer/%{path}#L%{line}" 50 | ] 51 | end 52 | 53 | defp aliases do 54 | [ 55 | copy_docs: [ 56 | "compile", 57 | fn _argv -> 58 | Igniter.Installer.TaskHelpers.copy_docs() 59 | Mix.Task.reenable("compile") 60 | Mix.Task.run("compile") 61 | end 62 | ], 63 | docs: [ 64 | "copy_docs", 65 | "docs" 66 | ] 67 | ] 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /test/igniter/project/formatter_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Project.FormatterTest do 6 | use ExUnit.Case 7 | import Igniter.Test 8 | 9 | describe "import_dep/2" do 10 | test "regression: causes formatting to respect imported locals_without_parens" do 11 | test_project( 12 | files: %{ 13 | "test/formatter_test.exs" => """ 14 | defmodule FormatterTest do 15 | use ExUnit.Case 16 | use Mimic.DSL 17 | 18 | test "1" do 19 | expect Foo.add(x, y), do: x + y 20 | end 21 | end 22 | """ 23 | } 24 | ) 25 | |> Igniter.Project.Deps.add_dep({:mimic, "~> 1.7"}) 26 | |> Igniter.Project.Formatter.import_dep(:mimic) 27 | |> Igniter.update_elixir_file("test/formatter_test.exs", fn zipper -> 28 | with {:ok, zipper} <- Igniter.Code.Module.move_to_defmodule(zipper), 29 | {:ok, zipper} <- Igniter.Code.Common.move_to_do_block(zipper), 30 | zipper <- Igniter.Code.Common.maybe_move_to_block(zipper) do 31 | zipper = 32 | zipper 33 | |> Sourceror.Zipper.rightmost() 34 | |> Igniter.Code.Common.add_code(""" 35 | test "2" do 36 | expect Foo.subtract(x, y), do: x - y 37 | end 38 | """) 39 | 40 | {:ok, zipper} 41 | end 42 | end) 43 | |> assert_has_patch("test/formatter_test.exs", """ 44 | | end 45 | + | 46 | + | test "2" do 47 | + | expect Foo.subtract(x, y), 48 | + | do: x - y 49 | + | end 50 | |end 51 | """) 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /installer/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "earmark_parser": {:hex, :earmark_parser, "1.4.39", "424642f8335b05bb9eb611aa1564c148a8ee35c9c8a8bba6e129d51a3e3c6769", [:mix], [], "hexpm", "06553a88d1f1846da9ef066b87b57c6f605552cfbe40d20bd8d59cc6bde41944"}, 3 | "ex_doc": {:hex, :ex_doc, "0.34.0", "ab95e0775db3df71d30cf8d78728dd9261c355c81382bcd4cefdc74610bef13e", [:mix], [{:earmark_parser, "~> 1.4.39", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "60734fb4c1353f270c3286df4a0d51e65a2c1d9fba66af3940847cc65a8066d7"}, 4 | "makeup": {:hex, :makeup, "1.1.2", "9ba8837913bdf757787e71c1581c21f9d2455f4dd04cfca785c70bbfff1a76a3", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "cce1566b81fbcbd21eca8ffe808f33b221f9eee2cbc7a1706fc3da9ff18e6cac"}, 5 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.2", "627e84b8e8bf22e60a2579dad15067c755531fea049ae26ef1020cad58fe9578", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "41193978704763f6bbe6cc2758b84909e62984c7752b3784bd3c218bb341706b"}, 6 | "makeup_erlang": {:hex, :makeup_erlang, "1.0.0", "6f0eff9c9c489f26b69b61440bf1b238d95badae49adac77973cbacae87e3c2e", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "ea7a9307de9d1548d2a72d299058d1fd2339e3d398560a0e46c27dab4891e4d2"}, 7 | "nimble_parsec": {:hex, :nimble_parsec, "1.4.0", "51f9b613ea62cfa97b25ccc2c1b4216e81df970acd8e16e8d1bdc58fef21370d", [:mix], [], "hexpm", "9c565862810fb383e9838c1dd2d7d2c437b3d13b267414ba6af33e50d2d1cf28"}, 8 | } 9 | -------------------------------------------------------------------------------- /test/igniter/project/task_aliases_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Project.TaskAliasesTest do 6 | use ExUnit.Case 7 | import Igniter.Test 8 | 9 | describe "add_alias/3-4" do 10 | test "adds a task alias to the `mix.exs` file" do 11 | test_project() 12 | |> Igniter.Project.TaskAliases.add_alias("test", "test --special") 13 | |> assert_has_patch("mix.exs", """ 14 | 10 + | deps: deps(), 15 | 11 + | aliases: aliases() 16 | """) 17 | |> assert_has_patch("mix.exs", """ 18 | 30 + | defp aliases() do 19 | 31 + | [test: "test --special"] 20 | 32 + | end 21 | """) 22 | end 23 | 24 | test "by default, it ignores existing aliases" do 25 | test_project() 26 | |> Igniter.Project.TaskAliases.add_alias("test", "test --special") 27 | |> apply_igniter!() 28 | |> Igniter.Project.TaskAliases.add_alias("test", "my_thing.setup_tests") 29 | |> assert_unchanged() 30 | end 31 | 32 | test "the alter option can be used to modify existing aliases" do 33 | test_project() 34 | |> Igniter.Project.TaskAliases.add_alias("test", "test --special") 35 | |> apply_igniter!() 36 | |> Igniter.Project.TaskAliases.add_alias("test", "my_thing.setup_tests", 37 | if_exists: :prepend 38 | ) 39 | |> assert_has_patch("mix.exs", """ 40 | 31 - | [test: "test --special"] 41 | 31 + | [test: ["my_thing.setup_tests", "test --special"]] 42 | """) 43 | end 44 | 45 | test "the alter option won't add steps that are already present" do 46 | test_project() 47 | |> Igniter.Project.TaskAliases.add_alias("test", ["my_thing.setup_tests", "test --special"]) 48 | |> apply_igniter!() 49 | |> Igniter.Project.TaskAliases.add_alias("test", "my_thing.setup_tests", 50 | if_exists: :prepend 51 | ) 52 | |> assert_unchanged() 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/igniter/code/file_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Code.FileTest do 6 | use ExUnit.Case 7 | 8 | test "files will be created if they do not exist" do 9 | %{rewrite: rewrite} = 10 | Igniter.new() 11 | |> Igniter.create_new_file("lib/foo/bar/something.heex", """ 12 |
Hello
13 | """) 14 | |> Igniter.prepare_for_write() 15 | 16 | assert rewrite 17 | |> Rewrite.source!("lib/foo/bar/something.heex") 18 | |> Rewrite.Source.get(:content) == """ 19 |
Hello
20 | """ 21 | end 22 | 23 | test "files will be read if they exist" do 24 | assert %{rewrite: rewrite} = 25 | Igniter.new() 26 | |> Igniter.include_existing_file("README.md", required?: true) 27 | 28 | assert rewrite 29 | |> Rewrite.source!("README.md") 30 | |> Rewrite.Source.get(:content) =~ "code generation" 31 | end 32 | 33 | test "can update file if it exists" do 34 | assert %{rewrite: rewrite} = 35 | Igniter.new() 36 | |> Igniter.include_existing_file("README.md", required?: true) 37 | |> Igniter.update_file("README.md", fn source -> 38 | Rewrite.Source.update(source, :content, "Hello Test") 39 | end) 40 | |> Igniter.prepare_for_write() 41 | 42 | assert rewrite 43 | |> Rewrite.source!("README.md") 44 | |> Rewrite.Source.get(:content) == "Hello Test" 45 | end 46 | 47 | test "can create folder" do 48 | assert %{mkdirs: mkdirs} = 49 | Igniter.new() 50 | |> Igniter.mkdir("empty_folder") 51 | 52 | assert mkdirs == ["empty_folder"] 53 | end 54 | 55 | test "can only create folder inside project directory" do 56 | %{mkdirs: mkdirs} = 57 | Igniter.new() 58 | |> Igniter.mkdir("../empty_folder") 59 | 60 | assert mkdirs == [] 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /usage-rules.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | # Rules for working with Igniter 9 | 10 | ## Understanding Igniter 11 | 12 | Igniter is a code generation and project patching framework that enables semantic manipulation of Elixir codebases. It provides tools for creating intelligent generators that can both create new files and modify existing ones safely. Igniter works with AST (Abstract Syntax Trees) through Sourceror.Zipper to make precise, context-aware changes to your code. 13 | 14 | ## Available Modules 15 | 16 | ### Project-Level Modules (`Igniter.Project.*`) 17 | 18 | - **`Igniter.Project.Application`** - Working with Application modules and application configuration 19 | - **`Igniter.Project.Config`** - Modifying Elixir config files (config.exs, runtime.exs, etc.) 20 | - **`Igniter.Project.Deps`** - Managing dependencies declared in mix.exs 21 | - **`Igniter.Project.Formatter`** - Interacting with .formatter.exs files 22 | - **`Igniter.Project.IgniterConfig`** - Managing .igniter.exs configuration files 23 | - **`Igniter.Project.MixProject`** - Updating project configuration in mix.exs 24 | - **`Igniter.Project.Module`** - Creating and managing modules with proper file placement 25 | - **`Igniter.Project.TaskAliases`** - Managing task aliases in mix.exs 26 | - **`Igniter.Project.Test`** - Working with test and test support files 27 | 28 | ### Code-Level Modules (`Igniter.Code.*`) 29 | 30 | - **`Igniter.Code.Common`** - General purpose utilities for working with Sourceror.Zipper 31 | - **`Igniter.Code.Function`** - Working with function definitions and calls 32 | - **`Igniter.Code.Keyword`** - Manipulating keyword lists 33 | - **`Igniter.Code.List`** - Working with lists in AST 34 | - **`Igniter.Code.Map`** - Manipulating maps 35 | - **`Igniter.Code.Module`** - Working with module definitions and usage 36 | - **`Igniter.Code.String`** - Utilities for string literals 37 | - **`Igniter.Code.Tuple`** - Working with tuples 38 | -------------------------------------------------------------------------------- /lib/igniter/code/tuple.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Code.Tuple do 6 | @moduledoc """ 7 | Utilities for working with tuples. 8 | """ 9 | alias Igniter.Code.Common 10 | alias Sourceror.Zipper 11 | 12 | @doc "Returns `true` if the zipper is at a literal tuple, `false` if not." 13 | @spec tuple?(Zipper.t()) :: boolean() 14 | def tuple?(item) do 15 | item = Igniter.Code.Common.maybe_move_to_single_child_block(item) 16 | 17 | case item.node do 18 | {:{}, _, _} -> true 19 | {_, _} -> true 20 | _ -> false 21 | end 22 | end 23 | 24 | @doc "Appends `quoted` to the elem" 25 | @spec append_elem(Zipper.t(), quoted :: Macro.t()) :: {:ok, Zipper.t()} | :error 26 | def append_elem(zipper, quoted) do 27 | if tuple?(zipper) do 28 | zipper = Igniter.Code.Common.maybe_move_to_single_child_block(zipper) 29 | 30 | case zipper.node do 31 | {l, r} -> 32 | {:ok, Zipper.replace(zipper, {:{}, [], [l, r, quoted]})} 33 | 34 | {:{}, _, list} -> 35 | {:ok, Zipper.replace(zipper, {:{}, [], list ++ [quoted]})} 36 | end 37 | else 38 | :error 39 | end 40 | end 41 | 42 | @spec elem_equals?(Zipper.t(), elem :: non_neg_integer(), value :: term) :: boolean() 43 | def elem_equals?(zipper, elem, value) do 44 | case tuple_elem(zipper, elem) do 45 | {:ok, zipper} -> 46 | Igniter.Code.Common.nodes_equal?(zipper, value) 47 | 48 | _ -> 49 | false 50 | end 51 | end 52 | 53 | @doc "Returns a zipper at the tuple element at the given index, or `:error` if the index is out of bounds." 54 | @spec tuple_elem(Zipper.t(), elem :: non_neg_integer()) :: {:ok, Zipper.t()} | :error 55 | def tuple_elem(item, elem) do 56 | item 57 | |> Common.maybe_move_to_single_child_block() 58 | |> Zipper.down() 59 | |> Common.move_right(elem) 60 | |> case do 61 | {:ok, nth} -> 62 | {:ok, Common.maybe_move_to_single_child_block(nth)} 63 | 64 | :error -> 65 | :error 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/mix/tasks/igniter.install_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Igniter.InstallTest do 6 | use ExUnit.Case 7 | 8 | setup do 9 | File.rm_rf!("test_project") 10 | cmd!("mix", ["new", "test_project"]) 11 | 12 | mix_exs = File.read!("test_project/mix.exs") 13 | 14 | new_contents = 15 | mix_exs 16 | |> add_igniter_dep() 17 | |> Code.format_string!() 18 | 19 | File.write!("test_project/mix.exs", new_contents) 20 | cmd!("mix", ["deps.get"], cd: "test_project") 21 | 22 | on_exit(fn -> 23 | File.rm_rf!("test_project") 24 | end) 25 | end 26 | 27 | describe "installing a new project" do 28 | test "basic installer works" do 29 | cmd!("mix", ["deps.compile"], cd: "test_project") 30 | output = cmd!("mix", ["igniter.install", "jason"], cd: "test_project") 31 | refute String.contains?(output, "jason\nCompiling") 32 | end 33 | 34 | test "displays additional information with `--verbose` option" do 35 | output = cmd!("mix", ["igniter.install", "jason", "--verbose"], cd: "test_project") 36 | assert String.contains?(output, "jason\nCompiling") 37 | end 38 | 39 | test "rerunning the same installer lets you know the dependency was not changed" do 40 | _ = cmd!("mix", ["igniter.install", "jason"], cd: "test_project") 41 | output = cmd!("mix", ["igniter.install", "jason"], cd: "test_project") 42 | 43 | assert String.contains?( 44 | output, 45 | "Dependency jason is already in mix.exs with the desired version. Skipping." 46 | ) 47 | end 48 | end 49 | 50 | defp add_igniter_dep(contents) do 51 | String.replace( 52 | contents, 53 | "defp deps do\n [\n", 54 | "defp deps do\n [\n {:igniter, path: \"../\"},\n" 55 | ) 56 | end 57 | 58 | defp cmd!(cmd, args, opts \\ []) do 59 | {output, status} = System.cmd(cmd, args, opts) 60 | assert status == 0, "Command failed with exit code #{status}: #{cmd} #{inspect(args)}" 61 | 62 | output 63 | end 64 | end 65 | -------------------------------------------------------------------------------- /lib/mix/tasks/igniter.upgrade.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Igniter.Upgrade do 6 | use Igniter.Mix.Task 7 | 8 | @example "mix igniter.upgrade package1 package2@1.2.1" 9 | 10 | @shortdoc "Fetch and upgrade dependencies. A drop in replacement for `mix deps.update` that also runs upgrade tasks." 11 | @moduledoc """ 12 | #{@shortdoc} 13 | 14 | Updates dependencies via `mix deps.update` and then runs any upgrade tasks for any changed dependencies. 15 | 16 | By default, this task updates to the latest versions allowed by the `mix.exs` file, just like `mix deps.update`. 17 | 18 | To upgrade a package to a specific version, you can specify the version after the package name, 19 | separated by an `@` symbol. This allows upgrading beyond what your mix.exs file currently specifies, 20 | i.e if you have `~> 1.0` in your mix.exs file, you can use `mix igniter.upgrade package@2.0` to 21 | upgrade to version 2.0, which will update your `mix.exs` and run any equivalent upgraders. 22 | 23 | ## Limitations 24 | 25 | The new version of the package must be "compile compatible" with your existing code. See the upgrades guide for more. 26 | 27 | ## Example 28 | 29 | ```bash 30 | #{@example} 31 | ``` 32 | 33 | ## Options 34 | 35 | * `--yes` - Accept all changes automatically 36 | * `--all` - Upgrades all dependencies 37 | * `--only` - only fetches dependencies for given environment 38 | * `--verbose` - display additional output from various operations 39 | * `--target` - only fetches dependencies for given target 40 | * `--no-archives-check` - does not check archives before fetching deps 41 | * `--git-ci` - Uses git history (HEAD~1) to check the previous versions in the lock file. 42 | See the upgrade guides for more. Sets --yes automatically. 43 | """ 44 | 45 | def info(_argv, _composing_task) do 46 | %Igniter.Mix.Task.Info{ 47 | group: :igniter, 48 | example: @example, 49 | positional: [ 50 | packages: [rest: true, optional: true] 51 | ], 52 | schema: Igniter.CopiedTasks.upgrade_switches(), 53 | # if we add aliases, put them in upgrade switches 54 | aliases: [], 55 | defaults: [yes: false, yes_to_deps: false] 56 | } 57 | end 58 | 59 | def igniter(igniter) do 60 | Igniter.Upgrades.upgrade(igniter) 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/igniter/upgrades/igniter.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Upgrades.Igniter do 6 | @moduledoc false 7 | 8 | alias Igniter.Code.Common 9 | alias Igniter.Code.Function 10 | alias Igniter.Code.Module 11 | alias Sourceror.Zipper 12 | 13 | require Common 14 | require Function 15 | 16 | @doc """ 17 | Rewrites deprecated `igniter/2` callback to `igniter/1` if the module 18 | is an `Igniter.Mix.Task`. 19 | """ 20 | @spec rewrite_deprecated_igniter_callback(Zipper.t()) :: {:ok, Zipper.t()} | :error 21 | def rewrite_deprecated_igniter_callback(%Zipper{} = zipper) do 22 | with {:ok, zipper} <- Module.move_to_module_using(zipper, [Igniter.Mix.Task]), 23 | {:ok, zipper} <- Common.move_to_pattern(zipper, {:def, _, [{:igniter, _, [_, _]} | _]}) do 24 | with :error <- remove_ignored_argv(zipper), 25 | :error <- replace_generated_argv_usage(zipper) do 26 | {:ok, zipper} 27 | end 28 | else 29 | _ -> {:ok, zipper} 30 | end 31 | end 32 | 33 | defp remove_ignored_argv(zipper) do 34 | with %Zipper{node: {argv_var, _, nil}} <- 35 | Zipper.search_pattern(zipper, "igniter(__, __cursor__())"), 36 | "_" <> _ <- to_string(argv_var) do 37 | remove_argv_arg(zipper) 38 | else 39 | _ -> :error 40 | end 41 | end 42 | 43 | defp replace_generated_argv_usage(zipper) do 44 | with {:ok, zipper} <- remove_argv_arg(zipper), 45 | {:ok, zipper} <- Common.move_to_do_block(zipper), 46 | zipper <- Common.maybe_move_to_block(zipper), 47 | true <- generated_argv_usage?(zipper) do 48 | zipper = 49 | zipper 50 | |> Zipper.remove() 51 | |> Zipper.next() 52 | |> Common.replace_code(""" 53 | arguments = igniter.args.positional 54 | options = igniter.args.options 55 | argv = igniter.args.argv_flags 56 | """) 57 | 58 | {:ok, zipper} 59 | else 60 | _ -> :error 61 | end 62 | end 63 | 64 | defp generated_argv_usage?(zipper) do 65 | with ^zipper <- Zipper.search_pattern(zipper, "{arguments, argv} = positional_args!(argv)"), 66 | {:ok, zipper} <- Common.move_right(zipper, 1), 67 | ^zipper <- Zipper.search_pattern(zipper, "options = options!(argv)") do 68 | true 69 | else 70 | _ -> false 71 | end 72 | end 73 | 74 | defp remove_argv_arg(zipper) do 75 | Common.within(zipper, fn zipper -> 76 | case Zipper.search_pattern(zipper, "igniter(__, __cursor__())") do 77 | %Zipper{} = zipper -> {:ok, Zipper.remove(zipper)} 78 | _ -> :error 79 | end 80 | end) 81 | end 82 | end 83 | -------------------------------------------------------------------------------- /lib/igniter/util/io.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Util.IO do 6 | @moduledoc "Helpers for working with input/output" 7 | 8 | @doc "Prompts the user for yes or no, repeating the prompt until a satisfactory answer is given" 9 | def yes?(prompt) do 10 | case Mix.shell().prompt(prompt <> " [Y/n]") do 11 | :eof -> 12 | raise "No input detected when asking for confirmation, perhaps you meant to use `--yes`?" 13 | 14 | str -> 15 | case String.trim(str) do 16 | # default answer Y 17 | "" -> 18 | true 19 | 20 | yes when yes in ["y", "Y", "yes", "YES"] -> 21 | true 22 | 23 | no when no in ["n", "N", "no", "NO"] -> 24 | false 25 | 26 | value -> 27 | Mix.shell().info("Please enter one of [y/n]. Got: #{value}") 28 | yes?(prompt) 29 | end 30 | end 31 | end 32 | 33 | @doc """ 34 | Prompts the user to select from a list, repeating until an item is selected 35 | 36 | ## Options 37 | 38 | - `display`: A function that takes an item and returns a string to display 39 | """ 40 | def select(prompt, items, opts \\ []) 41 | 42 | def select(_prompt, [], _opts), do: nil 43 | def select(_prompt, [item], _opts), do: item 44 | 45 | def select(prompt, items, opts) do 46 | display = Keyword.get(opts, :display, &to_string/1) 47 | 48 | item_numbers = 49 | items 50 | |> Enum.with_index() 51 | |> Enum.map_join("\n", fn {item, index} -> 52 | if Keyword.has_key?(opts, :default) && item == opts[:default] do 53 | "#{IO.ANSI.green()}#{index}.#{IO.ANSI.reset()} #{display.(item)} (Default)" 54 | else 55 | "#{index}. #{display.(item)}" 56 | end 57 | end) 58 | 59 | case String.trim(Mix.shell().prompt(prompt <> "\n" <> item_numbers <> "\nInput number ❯ ")) do 60 | "" -> 61 | case Keyword.fetch(opts, :default) do 62 | {:ok, value} -> 63 | value 64 | 65 | :error -> 66 | select(prompt, items, opts) 67 | end 68 | 69 | item -> 70 | case Integer.parse(item) do 71 | {int, ""} -> 72 | case Enum.at(items, int) do 73 | nil -> 74 | Mix.shell().info("Expected one of the provided numbers, got: #{item}") 75 | select(prompt, items, opts) 76 | 77 | value -> 78 | value 79 | end 80 | 81 | _ -> 82 | Mix.shell().info("Expected a number, got: #{item}") 83 | select(prompt, items, opts) 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/igniter/refactors/elixir_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Refactors.ElixirTest do 6 | use ExUnit.Case 7 | import Igniter.Test 8 | 9 | test "rewrites unless as an if with negated condition" do 10 | bad = "unless x, do: y" 11 | 12 | good = "if !x, do: y" 13 | 14 | assert_format(bad, good) 15 | 16 | bad = """ 17 | unless x do 18 | y 19 | else 20 | z 21 | end 22 | """ 23 | 24 | good = """ 25 | if !x do 26 | y 27 | else 28 | z 29 | end 30 | """ 31 | 32 | assert_format(bad, good) 33 | end 34 | 35 | test "rewrites pipelines with negated condition" do 36 | bad = "x |> unless(do: y)" 37 | 38 | good = "!x |> if(do: y)" 39 | 40 | assert_format(bad, good) 41 | 42 | bad = "x |> foo() |> unless(do: y)" 43 | 44 | good = "x |> foo() |> Kernel.!() |> if(do: y)" 45 | 46 | assert_format(bad, good) 47 | 48 | bad = "unless x |> foo(), do: y" 49 | 50 | good = """ 51 | if !(x |> foo()), 52 | do: y 53 | """ 54 | 55 | assert_format(bad, good) 56 | end 57 | 58 | test "rewrites in as not in" do 59 | assert_format("unless x in y, do: 1", "if x not in y, do: 1") 60 | end 61 | 62 | @tag :focus 63 | test "rewrites equality operators" do 64 | assert_format("unless x == y, do: 1", "if x != y, do: 1") 65 | assert_format("unless x === y, do: 1", "if x !== y, do: 1") 66 | assert_format("unless x != y, do: 1", "if x == y, do: 1") 67 | assert_format("unless x !== y, do: 1", "if x === y, do: 1") 68 | end 69 | 70 | test "rewrites boolean or is_* conditions with not" do 71 | assert_format("unless x > 0, do: 1", "if not (x > 0), do: 1") 72 | 73 | assert_format( 74 | "unless is_atom(x), do: 1", 75 | """ 76 | if not is_atom(x), 77 | do: 1 78 | """ 79 | ) 80 | end 81 | 82 | test "removes ! or not in condition" do 83 | assert_format("unless not x, do: 1", "if x, do: 1") 84 | assert_format("unless !x, do: 1", "if x, do: 1") 85 | end 86 | 87 | defp assert_format(code, expectation) do 88 | test_project() 89 | |> Igniter.create_new_file("lib/example.ex", """ 90 | defmodule Example do 91 | #{code} 92 | end 93 | """) 94 | |> Igniter.Refactors.Elixir.unless_to_if_not() 95 | |> assert_creates("lib/example.ex", """ 96 | defmodule Example do 97 | #{indent(expectation)} 98 | end 99 | """) 100 | end 101 | 102 | defp indent(string) do 103 | string 104 | |> String.split("\n", trim: true) 105 | |> Enum.map_join("\n", &" #{&1}") 106 | end 107 | end 108 | -------------------------------------------------------------------------------- /lib/igniter/phoenix/single.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | if Code.ensure_loaded?(Phx.New.Project) do 6 | defmodule Igniter.Phoenix.Single do 7 | @moduledoc false 8 | # Wrap Phx.New.Single 9 | # https://github.com/phoenixframework/phoenix/blob/7586cbee9e37afbe0b3cdbd560b9e6aa60d32bf6/installer/lib/phx_new/single.ex 10 | 11 | alias Igniter.Phoenix.Generator 12 | 13 | @mod Phx.New.Single 14 | 15 | def generate(igniter, project) do 16 | igniter = gen_new(igniter, project) 17 | 18 | igniter = 19 | if Keyword.get(project.binding, :ecto, false) do 20 | gen_ecto(igniter, project) 21 | else 22 | igniter 23 | end 24 | 25 | igniter = 26 | if Keyword.get(project.binding, :html, false) do 27 | gen_html(igniter, project) 28 | else 29 | igniter 30 | end 31 | 32 | igniter = 33 | if Keyword.get(project.binding, :mailer, false) do 34 | gen_mailer(igniter, project) 35 | else 36 | igniter 37 | end 38 | 39 | igniter = 40 | if Keyword.get(project.binding, :gettext, false) do 41 | gen_gettext(igniter, project) 42 | else 43 | igniter 44 | end 45 | 46 | gen_assets(igniter, project) 47 | end 48 | 49 | def gen_new(igniter, project) do 50 | Generator.copy_from(igniter, project, @mod, :new) 51 | end 52 | 53 | def gen_ecto(igniter, project) do 54 | igniter 55 | |> Generator.copy_from(project, @mod, :ecto) 56 | |> Generator.gen_ecto_config(project) 57 | end 58 | 59 | def gen_html(igniter, project) do 60 | Generator.copy_from(igniter, project, @mod, :html) 61 | end 62 | 63 | def gen_mailer(igniter, project) do 64 | Generator.copy_from(igniter, project, @mod, :mailer) 65 | end 66 | 67 | def gen_gettext(igniter, project) do 68 | Generator.copy_from(igniter, project, @mod, :gettext) 69 | end 70 | 71 | def gen_assets(igniter, project) do 72 | javascript? = Keyword.get(project.binding, :javascript, false) 73 | css? = Keyword.get(project.binding, :css, false) 74 | html? = Keyword.get(project.binding, :html, false) 75 | 76 | igniter = Generator.copy_from(igniter, project, @mod, :static) 77 | 78 | igniter = 79 | if html? or javascript? do 80 | command = if javascript?, do: :js, else: :no_js 81 | Generator.copy_from(igniter, project, @mod, command) 82 | else 83 | igniter 84 | end 85 | 86 | if html? or css? do 87 | command = if css?, do: :css, else: :no_css 88 | Generator.copy_from(igniter, project, @mod, command) 89 | else 90 | igniter 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/igniter/refactors/elixir.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Refactors.Elixir do 6 | @moduledoc "Refactors for changes in Elixir" 7 | 8 | @bool_operators [ 9 | :>, 10 | :>=, 11 | :<, 12 | :<=, 13 | :in 14 | ] 15 | @guards [ 16 | :is_atom, 17 | :is_boolean, 18 | :is_nil, 19 | :is_number, 20 | :is_integer, 21 | :is_float, 22 | :is_binary, 23 | :is_map, 24 | :is_struct, 25 | :is_non_struct_map, 26 | :is_exception, 27 | :is_list, 28 | :is_tuple, 29 | :is_function, 30 | :is_reference, 31 | :is_pid, 32 | :is_port 33 | ] 34 | 35 | @spec unless_to_if_not(Igniter.t()) :: Igniter.t() 36 | def unless_to_if_not(igniter) do 37 | Igniter.update_all_elixir_files(igniter, fn zipper -> 38 | # TODO: remap references in addition to calls 39 | Igniter.Code.Common.update_all_matches( 40 | zipper, 41 | fn zipper -> 42 | Igniter.Code.Function.function_call?(zipper, :unless, 2) 43 | end, 44 | fn zipper -> 45 | with {:ok, zipper} <- 46 | Igniter.Refactors.Rename.do_rename( 47 | zipper, 48 | {Kernel, :unless}, 49 | {Kernel, :if}, 50 | 2 51 | ), 52 | {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 0) do 53 | new_node = 54 | case zipper.node do 55 | {:in, meta, [l, r]} -> 56 | {:not, meta, [{:in, [], [l, r]}]} 57 | 58 | {:==, meta, [l, r]} -> 59 | {:!=, meta, [l, r]} 60 | 61 | {:!=, meta, [l, r]} -> 62 | {:==, meta, [l, r]} 63 | 64 | {:===, meta, [l, r]} -> 65 | {:!==, meta, [l, r]} 66 | 67 | {:!==, meta, [l, r]} -> 68 | {:===, meta, [l, r]} 69 | 70 | {neg, _, [condition]} when neg in [:!, :not] -> 71 | condition 72 | 73 | {op, _, [_, _]} when op in @bool_operators -> 74 | {:not, [], [zipper.node]} 75 | 76 | {guard, _, [_ | _]} when guard in @guards -> 77 | {:not, [], [zipper.node]} 78 | 79 | _ -> 80 | {:!, [], [zipper.node]} 81 | end 82 | 83 | {:ok, 84 | Igniter.Code.Common.replace_code( 85 | zipper, 86 | new_node 87 | )} 88 | else 89 | :error -> 90 | {:warning, 91 | """ 92 | Could not update the unless statement: 93 | 94 | #{Igniter.Util.Debug.code_at_node(zipper)} 95 | """} 96 | end 97 | end 98 | ) 99 | end) 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /test/igniter/code/keyword_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Code.KeywordTest do 6 | use ExUnit.Case 7 | 8 | test "remove_keyword_key removes the key" do 9 | assert "[a: 1]" == 10 | "[a: 1, b: 1]" 11 | |> Sourceror.parse_string!() 12 | |> Sourceror.Zipper.zip() 13 | |> Igniter.Code.Keyword.remove_keyword_key(:b) 14 | |> elem(1) 15 | |> Map.get(:node) 16 | |> Sourceror.to_string() 17 | end 18 | 19 | test "remove_keyword_key removes from a call, not adding brackets" do 20 | assert "foo(a: 1)" == 21 | "foo(a: 1, b: 1)" 22 | |> Sourceror.parse_string!() 23 | |> Sourceror.Zipper.zip() 24 | |> Sourceror.Zipper.down() 25 | |> Igniter.Code.Keyword.remove_keyword_key(:b) 26 | |> elem(1) 27 | |> Sourceror.Zipper.topmost_root() 28 | |> Sourceror.to_string() 29 | end 30 | 31 | test "remove_keyword_key removes from the second argument" do 32 | assert "foo(bar, a: 1)" == 33 | "foo bar, a: 1, b: 1" 34 | |> Sourceror.parse_string!() 35 | |> Sourceror.Zipper.zip() 36 | |> Igniter.Code.Function.move_to_nth_argument(1) 37 | |> elem(1) 38 | |> Igniter.Code.Keyword.remove_keyword_key(:b) 39 | |> elem(1) 40 | |> Sourceror.Zipper.topmost_root() 41 | |> Sourceror.to_string() 42 | end 43 | 44 | test "set_keyword_key passes through errors from updater" do 45 | zipper = 46 | "[a: 1]" 47 | |> Sourceror.parse_string!() 48 | |> Sourceror.Zipper.zip() 49 | 50 | assert {:error, "test error"} == 51 | Igniter.Code.Keyword.set_keyword_key(zipper, :a, 2, fn _ -> 52 | {:error, "test error"} 53 | end) 54 | end 55 | 56 | test "set_keyword_key passes through warnings from updater" do 57 | zipper = 58 | "[a: 1]" 59 | |> Sourceror.parse_string!() 60 | |> Sourceror.Zipper.zip() 61 | 62 | assert {:warning, "test warning"} == 63 | Igniter.Code.Keyword.set_keyword_key(zipper, :a, 2, fn _ -> 64 | {:warning, "test warning"} 65 | end) 66 | end 67 | 68 | test "put_in_keyword passes through errors from updater" do 69 | zipper = 70 | "[a: [b: 1]]" 71 | |> Sourceror.parse_string!() 72 | |> Sourceror.Zipper.zip() 73 | 74 | assert {:error, "test error"} == 75 | Igniter.Code.Keyword.put_in_keyword(zipper, [:a, :b], 2, fn _ -> 76 | {:error, "test error"} 77 | end) 78 | end 79 | 80 | test "put_in_keyword passes through warnings from updater" do 81 | zipper = 82 | "[a: [b: 1]]" 83 | |> Sourceror.parse_string!() 84 | |> Sourceror.Zipper.zip() 85 | 86 | assert {:warning, "test warning"} == 87 | Igniter.Code.Keyword.put_in_keyword(zipper, [:a, :b], 2, fn _ -> 88 | {:warning, "test warning"} 89 | end) 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /documentation/configuring-igniter.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | # Configuring Igniter 9 | 10 | This guide is for those who are _end-users_ of igniter, for example, using the generators provided by a library that are backed by igniter. 11 | 12 | ## Setting up igniter 13 | 14 | Use `mix igniter.setup` to create a `.igniter.exs` file in your project root. This file configures igniter for your project. You can run this command repeatedly to keep the file up to date over time. 15 | 16 | See the documentation in `Igniter.Project.IgniterConfig` for available configuration. 17 | 18 | ## Extensions 19 | 20 | Igniter supports extensions. These extensions are limited to determining where modules should be created (i.e a module in `/web` ending in `Controller`). 21 | This is not bulletproof and will likely need to be improved over time. (The best thing would be if Phoenix conventions were the same as the 22 | elixir conventions of module names matching paths). To this end, you will want to add the phoenix extension if your generator builds any phoenix-related modules. 23 | 24 | For an end user, this can be done with `mix igniter.add_extension phoenix`. 25 | 26 | For those writing tasks, use `Igniter.compose_task("igniter.add_extension", ["phoenix"])`. 27 | 28 | ## Moving files 29 | 30 | One available configuration is `module_location`. This configuration dictates where modules are placed when there is a folder that exactly matches their module name. There are two available strategies for this, and with igniter not only can you change your mind, but you can actually _move back and forth_ between each strategy. To move any modules to their rightful place, use `mix igniter.move_files`. 31 | 32 | > ### Only for matching modules {: .tip} 33 | > 34 | > The following rules are _only applied_ when a top-level module is defined in the file. If it is not, then the file will always be left exactly where it is. It is generally considered best-practice to define one top-level module per file. 35 | 36 | ## `:outside_matching_folder` 37 | 38 | The "standard" way to place a module is to place it in a folder path that exactly matches its module name, inside of `lib/`. For example, a module named `MyApp.MyModule` would be placed in `lib/my_app/my_module.ex`. 39 | 40 | Use the default `:outside_matching_folder` to follow this convention in all cases. 41 | 42 | ## `:inside_matching_folder` 43 | 44 | What some people don't like about the previously described strategy is that it can split up related modules. For example: 45 | 46 | ``` 47 | lib/ 48 | └── my_app/ 49 | ├── accounts/ 50 | │ ├── user.ex 51 | │ ├── organization.ex 52 | ├── social/ 53 | │ ├── post.ex 54 | │ ├── comment.ex 55 | ├── accounts.ex # <- This feels to some like it should be in `/accounts` 56 | └── social.ex 57 | ``` 58 | 59 | They would prefer to put that leaf-node module in its matching folder _if it exists_, and otherwise follow the original convention if not. 60 | 61 | ``` 62 | lib/ 63 | └── my_app/ 64 | ├── accounts/ 65 | │ ├── user.ex 66 | │ ├── organization.ex 67 | │ ├── accounts.ex 68 | ├── social/ 69 | │ ├── post.ex 70 | │ ├── comment.ex 71 | │ └── social.ex 72 | ``` 73 | -------------------------------------------------------------------------------- /test/igniter/code/map_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2025 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Code.Map.Test do 6 | alias Igniter.Code 7 | alias Sourceror.Zipper 8 | 9 | use ExUnit.Case 10 | 11 | doctest Igniter.Code.Map 12 | 13 | describe "set_map_key/4" do 14 | test "inserts value into empty map" do 15 | {:ok, zipper} = 16 | "%{}" 17 | |> Code.Common.parse_to_zipper!() 18 | |> Code.Map.set_map_key(:foo, :bar, fn _valuex -> flunk() end) 19 | 20 | assert {:ok, %{foo: :bar}} == Code.Common.expand_literal(zipper) 21 | end 22 | 23 | test "inserts value into map if not present" do 24 | {:ok, zipper} = 25 | "%{hello: :world}" 26 | |> Code.Common.parse_to_zipper!() 27 | |> Code.Map.set_map_key(:foo, :bar, &flunk/1) 28 | 29 | assert {:ok, %{foo: :bar, hello: :world}} == Code.Common.expand_literal(zipper) 30 | end 31 | 32 | test "replaces keys in map with the updater rather than the passed value" do 33 | {:ok, zipper} = 34 | "%{hello: :world}" 35 | |> Code.Common.parse_to_zipper!() 36 | |> Code.Map.set_map_key(:hello, :this_value_is_ignored, fn %Zipper{node: :world} = zipper -> 37 | Zipper.replace(zipper, :baz) 38 | end) 39 | 40 | assert {:ok, %{hello: :baz}} == Code.Common.expand_literal(zipper) 41 | end 42 | end 43 | 44 | describe "put_in_map/4" do 45 | test "inserts value into empty map" do 46 | {:ok, zipper} = 47 | "%{}" 48 | |> Code.Common.parse_to_zipper!() 49 | |> Code.Map.put_in_map([:foo], :bar, fn _valuex -> flunk() end) 50 | 51 | assert {:ok, %{foo: :bar}} == Code.Common.expand_literal(zipper) 52 | end 53 | 54 | test "inserts value into empty map at nested position" do 55 | {:ok, zipper} = 56 | "%{}" 57 | |> Code.Common.parse_to_zipper!() 58 | |> Code.Map.put_in_map([:alpha, :beta, :gamma], :abc, fn _valuex -> flunk() end) 59 | 60 | assert {:ok, %{alpha: %{beta: %{gamma: :abc}}}} == Code.Common.expand_literal(zipper) 61 | end 62 | 63 | test "inserts value into map if not present" do 64 | {:ok, zipper} = 65 | "%{hello: :world}" 66 | |> Code.Common.parse_to_zipper!() 67 | |> Code.Map.put_in_map([:foo], :bar, fn _valuex -> flunk() end) 68 | 69 | assert {:ok, %{hello: :world, foo: :bar}} == 70 | Code.Common.expand_literal(zipper) 71 | end 72 | 73 | test "inserts nested value into map if not present" do 74 | {:ok, zipper} = 75 | "%{hello: :world}" 76 | |> Code.Common.parse_to_zipper!() 77 | |> Code.Map.put_in_map([:foo, :bar], :baz, fn _valuex -> flunk() end) 78 | 79 | assert {:ok, %{hello: :world, foo: %{bar: :baz}}} == 80 | Code.Common.expand_literal(zipper) 81 | end 82 | 83 | test "inserts value into map if not present at nested position" do 84 | {:ok, zipper} = 85 | "%{alpha: %{beta: %{delta: :abd}}}" 86 | |> Code.Common.parse_to_zipper!() 87 | |> Code.Map.put_in_map([:alpha, :beta, :gamma], :abc, fn _valuex -> flunk() end) 88 | 89 | assert {:ok, %{alpha: %{beta: %{delta: :abd, gamma: :abc}}}} == 90 | Code.Common.expand_literal(zipper) 91 | end 92 | end 93 | end 94 | -------------------------------------------------------------------------------- /test/igniter/project/test_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Project.TestTest do 6 | use ExUnit.Case 7 | 8 | alias Rewrite.Source 9 | 10 | describe "ensure_test_support" do 11 | test "it adds the path if it doesn't exist" do 12 | assert %{rewrite: rewrite} = 13 | Igniter.new() 14 | |> Igniter.include_existing_file("mix.exs") 15 | |> Map.update!(:rewrite, fn rewrite -> 16 | source = Rewrite.source!(rewrite, "mix.exs") 17 | 18 | source = 19 | Source.update(source, :content, """ 20 | defmodule Igniter.MixProject do 21 | use Mix.Project 22 | 23 | def project do 24 | [ 25 | app: :igniter 26 | ] 27 | end 28 | end 29 | """) 30 | 31 | Rewrite.update!(rewrite, source) 32 | end) 33 | |> Igniter.Project.Test.ensure_test_support() 34 | 35 | contents = 36 | rewrite 37 | |> Rewrite.source!("mix.exs") 38 | |> Source.get(:content) 39 | 40 | assert String.contains?(contents, "elixirc_paths: elixirc_paths(Mix.env())") 41 | 42 | assert String.contains?( 43 | contents, 44 | """ 45 | defp elixirc_paths(:test), 46 | do: elixirc_paths(:dev) ++ ["test/support"] 47 | 48 | defp elixirc_paths(_), 49 | do: ["lib"] 50 | """ 51 | |> String.trim() 52 | ) 53 | end 54 | 55 | test "it doesn't change anything if the setting is already configured" do 56 | assert %{rewrite: rewrite} = 57 | Igniter.new() 58 | |> Igniter.include_existing_file("mix.exs") 59 | |> Map.update!(:rewrite, fn rewrite -> 60 | source = Rewrite.source!(rewrite, "mix.exs") 61 | 62 | source = 63 | Source.update(source, :content, """ 64 | defmodule Igniter.MixProject do 65 | use Mix.Project 66 | 67 | def project do 68 | [ 69 | app: :igniter, 70 | elixirc_paths: ["foo/bar"] 71 | ] 72 | end 73 | end 74 | """) 75 | 76 | Rewrite.update!(rewrite, source) 77 | end) 78 | |> Igniter.Project.Test.ensure_test_support() 79 | 80 | contents = 81 | rewrite 82 | |> Rewrite.source!("mix.exs") 83 | |> Source.get(:content) 84 | 85 | assert contents == """ 86 | defmodule Igniter.MixProject do 87 | use Mix.Project 88 | 89 | def project do 90 | [ 91 | app: :igniter, 92 | elixirc_paths: ["foo/bar"] 93 | ] 94 | end 95 | end 96 | """ 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/mix/tasks/igniter.update_gettext.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Igniter.UpdateGettext do 6 | use Igniter.Mix.Task 7 | 8 | @shortdoc "Applies changes to resolve a warning introduced in gettext 0.26.0" 9 | @moduledoc """ 10 | #{@shortdoc} 11 | """ 12 | 13 | def info(_argv, _source) do 14 | %Igniter.Mix.Task.Info{group: :igniter} 15 | end 16 | 17 | def igniter(igniter) do 18 | {igniter, modules} = find_use_gettext_modules(igniter) 19 | 20 | modules 21 | |> Enum.reduce(igniter, fn module, igniter -> 22 | igniter 23 | |> use_gettext_backend(module) 24 | |> rewrite_imports(module) 25 | end) 26 | |> Igniter.Project.Deps.add_dep( 27 | {:gettext, "~> 0.26 and >= 0.26.1"}, 28 | yes?: true 29 | ) 30 | end 31 | 32 | defp rewrite_imports(igniter, rewriting_module) do 33 | {igniter, modules} = 34 | Igniter.Project.Module.find_all_matching_modules(igniter, fn _module, zipper -> 35 | match?( 36 | {:ok, _}, 37 | Igniter.Code.Common.move_to(zipper, fn zipper -> 38 | import?(zipper, rewriting_module) 39 | end) 40 | ) 41 | end) 42 | 43 | Enum.reduce(modules, igniter, fn module, igniter -> 44 | Igniter.Project.Module.find_and_update_module!(igniter, module, fn zipper -> 45 | Igniter.Code.Common.update_all_matches(zipper, &import?(&1, rewriting_module), fn _ -> 46 | {:code, 47 | quote do 48 | use Gettext, backend: unquote(rewriting_module) 49 | end} 50 | end) 51 | end) 52 | end) 53 | end 54 | 55 | defp find_use_gettext_modules(igniter) do 56 | Igniter.Project.Module.find_all_matching_modules(igniter, fn _module, zipper -> 57 | with {:ok, zipper} <- Igniter.Code.Module.move_to_use(zipper, Gettext), 58 | false <- has_backend_arg?(zipper) do 59 | true 60 | else 61 | _ -> 62 | false 63 | end 64 | end) 65 | end 66 | 67 | defp import?(zipper, module) do 68 | Igniter.Code.Function.function_call?(zipper, :import, 1) && 69 | Igniter.Code.Function.argument_equals?(zipper, 0, module) 70 | end 71 | 72 | defp use_gettext_backend(igniter, module) do 73 | Igniter.Project.Module.find_and_update_module!(igniter, module, fn zipper -> 74 | with {:ok, zipper} <- Igniter.Code.Module.move_to_use(zipper, Gettext), 75 | false <- has_backend_arg?(zipper), 76 | {:ok, zipper} <- 77 | Igniter.Code.Function.update_nth_argument(zipper, 0, fn zipper -> 78 | {:ok, Igniter.Code.Common.replace_code(zipper, Gettext.Backend)} 79 | end) do 80 | {:ok, zipper} 81 | else 82 | true -> 83 | {:ok, zipper} 84 | 85 | _ -> 86 | {:warning, "Failed to update to Gettext.Backend in #{inspect(module)}"} 87 | end 88 | end) 89 | end 90 | 91 | defp has_backend_arg?(zipper) do 92 | with {:ok, zipper} <- Igniter.Code.Function.move_to_nth_argument(zipper, 1), 93 | {:ok, _zipper} <- Igniter.Code.Keyword.get_key(zipper, :backend) do 94 | true 95 | else 96 | _ -> 97 | false 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /test/mix/tasks/igniter.upgrade_igniter_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Igniter.UpgradeIgniterTest do 6 | use ExUnit.Case 7 | import Igniter.Test 8 | 9 | describe "igniter/2 -> igniter/1 upgrade" do 10 | test "does not affect non-Igniter.Mix.Task modules" do 11 | test_project( 12 | files: %{ 13 | "lib/mix/tasks/my_task.ex" => """ 14 | defmodule Mix.Tasks.MyTask do 15 | use Mix.Task 16 | 17 | def igniter(igniter, _argv) do 18 | igniter 19 | end 20 | 21 | def run(_argv) do 22 | :ok 23 | end 24 | end 25 | """ 26 | } 27 | ) 28 | |> Igniter.compose_task("igniter.upgrade_igniter", ["0.3.76", "0.4.0"]) 29 | |> assert_unchanged("lib/mix/tasks/my_task.ex") 30 | end 31 | end 32 | 33 | test "upgrades igniter/2 when argv is ignored" do 34 | test_project( 35 | files: %{ 36 | "lib/mix/tasks/my_task.ex" => """ 37 | defmodule Mix.Tasks.MyTask do 38 | use Igniter.Mix.Task 39 | 40 | def igniter(igniter, _argv) do 41 | igniter 42 | end 43 | end 44 | """ 45 | } 46 | ) 47 | |> Igniter.compose_task("igniter.upgrade_igniter", ["0.3.76", "0.4.0"]) 48 | |> assert_has_patch("lib/mix/tasks/my_task.ex", """ 49 | - | def igniter(igniter, _argv) do 50 | + | def igniter(igniter) do 51 | """) 52 | end 53 | 54 | test "upgrades igniter/2 when argv is used as generated" do 55 | test_project( 56 | files: %{ 57 | "lib/mix/tasks/my_task.ex" => """ 58 | defmodule Mix.Tasks.MyTask do 59 | use Igniter.Mix.Task 60 | 61 | def igniter(igniter, argv) do 62 | # extract positional arguments according to `positional` above 63 | {arguments, argv} = positional_args!(argv) 64 | # extract options according to `schema` and `aliases` above 65 | options = options!(argv) 66 | 67 | igniter 68 | end 69 | end 70 | """ 71 | } 72 | ) 73 | |> Igniter.compose_task("igniter.upgrade_igniter", ["0.3.76", "0.4.0"]) 74 | |> assert_has_patch("lib/mix/tasks/my_task.ex", """ 75 | - | def igniter(igniter, argv) do 76 | - | # extract positional arguments according to `positional` above 77 | - | {arguments, argv} = positional_args!(argv) 78 | - | # extract options according to `schema` and `aliases` above 79 | - | options = options!(argv) 80 | + | def igniter(igniter) do 81 | + | arguments = igniter.args.positional 82 | + | options = igniter.args.options 83 | + | argv = igniter.args.argv_flags 84 | | 85 | | igniter 86 | """) 87 | end 88 | 89 | test "doesn't upgrade igniter/2 when generated argv usage was modified" do 90 | test_project( 91 | files: %{ 92 | "lib/mix/tasks/my_task.ex" => """ 93 | defmodule Mix.Tasks.MyTask do 94 | use Igniter.Mix.Task 95 | 96 | def igniter(igniter, argv) do 97 | foo = {arguments, argv} = positional_args!(argv) 98 | bar = options = options!(argv) 99 | 100 | igniter 101 | end 102 | end 103 | """ 104 | } 105 | ) 106 | |> Igniter.compose_task("igniter.upgrade_igniter", ["0.3.76", "0.4.0"]) 107 | |> assert_unchanged() 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/mix/tasks/igniter.upgrade_igniter.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Igniter.UpgradeIgniter do 6 | use Igniter.Mix.Task 7 | 8 | @example "mix igniter.upgrade_igniter --example arg" 9 | 10 | @moduledoc false 11 | 12 | def info(_argv, _composing_task) do 13 | %Igniter.Mix.Task.Info{ 14 | # Groups allow for overlapping arguments for tasks by the same author 15 | # See the generators guide for more. 16 | group: :igniter, 17 | # dependencies to add 18 | adds_deps: [], 19 | # dependencies to add and call their associated installers, if they exist 20 | installs: [], 21 | # An example invocation 22 | example: @example, 23 | # a list of positional arguments, i.e `[:file]` 24 | positional: [:from, :to], 25 | # Other tasks your task composes using `Igniter.compose_task`, passing in the CLI argv 26 | # This ensures your option schema includes options from nested tasks 27 | composes: [], 28 | # `OptionParser` schema 29 | schema: [], 30 | # Default values for the options in the `schema`. 31 | defaults: [], 32 | # CLI aliases 33 | aliases: [], 34 | # A list of options in the schema that are required 35 | required: [] 36 | } 37 | end 38 | 39 | def igniter(igniter) do 40 | arguments = igniter.args.positional 41 | options = igniter.args.options 42 | 43 | upgrades = 44 | %{ 45 | "0.3.66" => [&code_module_parse_to_project_module_parse/2], 46 | "0.3.71" => [&code_module_parse_to_project_module_parse/2], 47 | "0.3.76" => [&code_common_nth_right_to_move_right/2], 48 | "0.4.0" => [&igniter2_to_igniter1/2] 49 | } 50 | 51 | # For each version that requires a change, add it to this map 52 | # Each key is a version that points at a function that takes an 53 | # igniter, an argv, and options (i.e flags or other custom options). 54 | # See the upgrades guide for more. 55 | Igniter.Upgrades.run(igniter, arguments.from, arguments.to, upgrades, options) 56 | end 57 | 58 | defp code_module_parse_to_project_module_parse(igniter, _opts) do 59 | igniter 60 | |> Igniter.Refactors.Rename.rename_function( 61 | {Igniter.Code.Module, :parse}, 62 | {Igniter.Project.Module, :parse}, 63 | arity: 1 64 | ) 65 | |> Igniter.add_notice( 66 | "Igniter.Code.Module.parse/1 was deprecated in favor of Igniter.Project.Module.parse/1" 67 | ) 68 | end 69 | 70 | defp code_common_nth_right_to_move_right(igniter, _opts) do 71 | igniter 72 | |> Igniter.Refactors.Rename.rename_function( 73 | {Igniter.Code.Common, :nth_right}, 74 | {Igniter.Code.Common, :move_right}, 75 | arity: 2 76 | ) 77 | |> Igniter.add_notice( 78 | "Igniter.Code.Common.nth_right/2 was deprecated in favor of Igniter.Code.Common.move_right/2" 79 | ) 80 | end 81 | 82 | defp igniter2_to_igniter1(igniter, _opts) do 83 | ignore_module_conflict(fn -> 84 | Igniter.Mix.Task.module_info()[:compile][:source] |> List.to_string() |> Code.compile_file() 85 | end) 86 | 87 | Igniter.update_all_elixir_files(igniter, fn zipper -> 88 | Igniter.Upgrades.Igniter.rewrite_deprecated_igniter_callback(zipper) 89 | end) 90 | end 91 | 92 | defp ignore_module_conflict(fun) when is_function(fun, 0) do 93 | original_compiler_opts = Code.compiler_options() 94 | Code.put_compiler_option(:ignore_module_conflict, true) 95 | result = fun.() 96 | Code.compiler_options(original_compiler_opts) 97 | result 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/mix/tasks/igniter.refactor.rename_function.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Igniter.Refactor.RenameFunction do 6 | use Igniter.Mix.Task 7 | 8 | @example "mix igniter.refactor.rename_function Mod.fun NewMod.new_fun" 9 | 10 | @shortdoc "Rename functions across a project with automatic reference updates." 11 | @moduledoc """ 12 | #{@shortdoc} 13 | 14 | Rename a given function across a whole project. 15 | This will remap definitions in addition to calls and references. 16 | 17 | Keep in mind that it cannot detect 100% of cases, and will always 18 | miss usage of `apply/3` for dynamic function calling. 19 | 20 | If the new module is different than the old module, the function will be moved. 21 | If the new module does not exist, it will be created. 22 | 23 | Pass an arity to the first function to only rename a specific arity definition. 24 | 25 | ## Options 26 | 27 | - `--deprecate` - `soft | hard` The old function will remain in place but deprecated. Soft deprecations, 28 | only affect documentation, while hard deprecations will display a warning when the function is called. 29 | 30 | ## Example 31 | 32 | ```bash 33 | #{@example} 34 | ``` 35 | """ 36 | 37 | def info(_argv, _composing_task) do 38 | %Igniter.Mix.Task.Info{ 39 | group: :igniter, 40 | example: @example, 41 | positional: [:old, :new], 42 | schema: [ 43 | deprecate: :string 44 | ] 45 | } 46 | end 47 | 48 | def igniter(igniter) do 49 | arguments = igniter.args.positional 50 | options = igniter.args.options 51 | 52 | deprecate = 53 | case options[:deprecate] do 54 | nil -> 55 | nil 56 | 57 | "hard" -> 58 | :hard 59 | 60 | "soft" -> 61 | :soft 62 | 63 | other -> 64 | Mix.shell().error("Invalid deprecation type: #{other}") 65 | exit({:shutdown, 1}) 66 | end 67 | 68 | {old_mod, old_fun, old_arity} = parse_fun(arguments[:old]) 69 | {new_mod, new_fun, new_arity} = parse_fun(arguments[:new]) 70 | 71 | arity = 72 | cond do 73 | is_integer(old_arity) and new_arity == :any -> 74 | old_arity 75 | 76 | old_arity != new_arity -> 77 | Mix.shell().error( 78 | "Arity must be the same between old and new function (or omitted for the new function)" 79 | ) 80 | 81 | exit({:shutdown, 1}) 82 | 83 | true -> 84 | old_arity 85 | end 86 | 87 | Igniter.Refactors.Rename.rename_function( 88 | igniter, 89 | {old_mod, old_fun}, 90 | {new_mod, new_fun}, 91 | arity: arity, 92 | deprecate: deprecate 93 | ) 94 | end 95 | 96 | def parse_fun(input) do 97 | with parts <- String.split(input, ".", trim: true), 98 | fun <- List.last(parts), 99 | parts <- :lists.droplast(parts), 100 | mod <- Enum.join(parts, "."), 101 | {fun, arity} <- fun_to_arity(fun), 102 | fun <- String.to_atom(fun), 103 | mod <- Igniter.Project.Module.parse(mod) do 104 | {mod, fun, arity} 105 | else 106 | _ -> 107 | Mix.shell().error("Invalid function format: #{input}") 108 | exit({:shutdown, 1}) 109 | end 110 | end 111 | 112 | defp fun_to_arity(fun) do 113 | case String.split(fun, "/", parts: 2, trim: true) do 114 | [fun] -> 115 | {fun, :any} 116 | 117 | [fun, arity] -> 118 | case Integer.parse(arity) do 119 | {arity, ""} -> 120 | {fun, arity} 121 | 122 | _ -> 123 | :error 124 | end 125 | 126 | _ -> 127 | :error 128 | end 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/igniter/phoenix/generator.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Phoenix.Generator do 6 | @moduledoc false 7 | # Wrap Phx.New.Generator 8 | # https://github.com/phoenixframework/phoenix/blob/7586cbee9e37afbe0b3cdbd560b9e6aa60d32bf6/installer/lib/phx_new/generator.ex#L69 9 | 10 | def copy_from(igniter, project, mod, name) when is_atom(name) do 11 | mapping = mod.template_files(name) 12 | 13 | templates = 14 | for {format, _project_location, files} <- mapping, 15 | {source, target_path} <- files, 16 | source = to_string(source) do 17 | target = expand_path_with_bindings(target_path, project) 18 | {format, source, target} 19 | end 20 | 21 | Enum.reduce(templates, igniter, fn {format, source, target}, acc -> 22 | case format do 23 | :keep -> 24 | acc 25 | 26 | :text -> 27 | contents = mod.render(name, source, project.binding) 28 | Igniter.create_new_file(acc, target, contents, on_exists: :overwrite) 29 | 30 | :config -> 31 | contents = mod.render(name, source, project.binding) 32 | config_inject(acc, target, contents) 33 | 34 | :prod_config -> 35 | contents = mod.render(name, source, project.binding) 36 | prod_only_config_inject(acc, target, contents) 37 | 38 | :eex -> 39 | contents = mod.render(name, source, project.binding) 40 | Igniter.create_new_file(acc, target, contents, on_exists: :overwrite) 41 | end 42 | end) 43 | end 44 | 45 | defp expand_path_with_bindings(path, project) do 46 | Regex.replace(Regex.recompile!(~r/:[a-zA-Z0-9_]+/), path, fn ":" <> key, _ -> 47 | project |> Map.fetch!(:"#{key}") |> to_string() 48 | end) 49 | end 50 | 51 | defp config_inject(igniter, file, to_inject) do 52 | patterns = [ 53 | """ 54 | import Config 55 | __cursor__() 56 | """ 57 | ] 58 | 59 | Igniter.create_or_update_elixir_file(igniter, file, to_inject, fn zipper -> 60 | case Igniter.Code.Common.move_to_cursor_match_in_scope(zipper, patterns) do 61 | {:ok, zipper} -> 62 | {:ok, Igniter.Code.Common.add_code(zipper, to_inject)} 63 | 64 | _ -> 65 | {:warning, 66 | """ 67 | Could not automatically inject the following config into #{file} 68 | 69 | #{to_inject} 70 | """} 71 | end 72 | end) 73 | end 74 | 75 | defp prod_only_config_inject(igniter, file, to_inject) do 76 | patterns = [ 77 | """ 78 | if config_env() == :prod do 79 | __cursor__() 80 | end 81 | """, 82 | """ 83 | if :prod == config_env() do 84 | __cursor__() 85 | end 86 | """ 87 | ] 88 | 89 | Igniter.create_or_update_elixir_file(igniter, file, to_inject, fn zipper -> 90 | case Igniter.Code.Common.move_to_cursor_match_in_scope(zipper, patterns) do 91 | {:ok, zipper} -> 92 | {:ok, Igniter.Code.Common.add_code(zipper, to_inject)} 93 | 94 | _ -> 95 | {:warning, 96 | """ 97 | Could not automatically inject the following config into #{file} 98 | 99 | #{to_inject} 100 | """} 101 | end 102 | end) 103 | end 104 | 105 | def gen_ecto_config(igniter, %{binding: binding}) do 106 | adapter_config = binding[:adapter_config] 107 | 108 | config_inject(igniter, "config/dev.exs", """ 109 | # Configure your database 110 | config :#{binding[:app_name]}, #{binding[:app_module]}.Repo#{kw_to_config(adapter_config[:dev])} 111 | """) 112 | end 113 | 114 | defp kw_to_config(kw) do 115 | Enum.map(kw, fn 116 | {k, {:literal, v}} -> ",\n #{k}: #{v}" 117 | {k, v} -> ",\n #{k}: #{inspect(v)}" 118 | end) 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /test/igniter/libs/ecto_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Libs.EctoTest do 6 | use ExUnit.Case, async: false 7 | import Igniter.Test 8 | 9 | setup do 10 | current_shell = Mix.shell() 11 | 12 | :ok = Mix.shell(Mix.Shell.Process) 13 | 14 | on_exit(fn -> 15 | Mix.shell(current_shell) 16 | end) 17 | end 18 | 19 | describe "list_repos" do 20 | test "returns the list of repos" do 21 | {_igniter, repos} = 22 | test_project() 23 | |> Igniter.Project.Module.create_module(Example.Repo, "use Ecto.Repo") 24 | |> Igniter.Project.Module.create_module(Example.Repo2, "use AshPostgres.Repo") 25 | |> Igniter.Libs.Ecto.list_repos() 26 | 27 | assert Enum.sort(repos) == [Example.Repo, Example.Repo2] 28 | end 29 | end 30 | 31 | describe "select_repo" do 32 | test "returns the selected repo" do 33 | send(self(), {:mix_shell_input, :prompt, "0"}) 34 | 35 | assert {_igniter, Example.Repo} = 36 | test_project() 37 | |> Igniter.Project.Module.create_module(Example.Repo, "use Ecto.Repo") 38 | |> Igniter.Project.Module.create_module(Example.Repo2, "use AshPostgres.Repo") 39 | |> Igniter.Libs.Ecto.select_repo(label: "Which repo would you like to use?") 40 | end 41 | end 42 | 43 | describe "gen_migration" do 44 | test "it generates a migration file" do 45 | test_project() 46 | |> Igniter.Libs.Ecto.gen_migration(Example.Repo, "create_users", 47 | body: """ 48 | def up do 49 | "up" 50 | end 51 | 52 | def down do 53 | "down" 54 | end 55 | """, 56 | timestamp: 00 57 | ) 58 | |> assert_creates("priv/repo/migrations/0_create_users.exs", """ 59 | defmodule Example.Repo.Migrations.CreateUsers do 60 | use Ecto.Migration 61 | 62 | def up do 63 | "up" 64 | end 65 | 66 | def down do 67 | "down" 68 | end 69 | end 70 | """) 71 | end 72 | 73 | test "it increments duplicates" do 74 | test_project() 75 | |> Igniter.Libs.Ecto.gen_migration(Example.Repo, "create_users", 76 | body: """ 77 | def up do 78 | "up" 79 | end 80 | 81 | def down do 82 | "down" 83 | end 84 | """, 85 | timestamp: 00 86 | ) 87 | |> apply_igniter!() 88 | |> Igniter.Libs.Ecto.gen_migration(Example.Repo, "create_users", 89 | body: """ 90 | def up do 91 | "up" 92 | end 93 | 94 | def down do 95 | "down" 96 | end 97 | """, 98 | timestamp: 00 99 | ) 100 | |> assert_creates("priv/repo/migrations/0_create_users_1.exs", """ 101 | defmodule Example.Repo.Migrations.CreateUsers1 do 102 | use Ecto.Migration 103 | 104 | def up do 105 | "up" 106 | end 107 | 108 | def down do 109 | "down" 110 | end 111 | end 112 | """) 113 | end 114 | end 115 | 116 | test "it overwrites existing file" do 117 | test_project() 118 | |> Igniter.Libs.Ecto.gen_migration(Example.Repo, "create_users", 119 | body: """ 120 | def up, do: "up old" 121 | def down, do: "down old" 122 | """, 123 | timestamp: 0 124 | ) 125 | |> Igniter.Libs.Ecto.gen_migration(Example.Repo, "create_users", 126 | body: """ 127 | def up, do: "up new" 128 | def down, do: "down new" 129 | """, 130 | timestamp: 0, 131 | on_exists: :overwrite 132 | ) 133 | |> assert_creates("priv/repo/migrations/0_create_users.exs", """ 134 | defmodule Example.Repo.Migrations.CreateUsers do 135 | use Ecto.Migration 136 | 137 | def up, do: "up new" 138 | def down, do: "down new" 139 | end 140 | """) 141 | end 142 | end 143 | -------------------------------------------------------------------------------- /documentation/upgrades.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | # Upgrades 9 | 10 | Igniter provides a mix task `mix igniter.upgrade` that is a drop-in replacement for 11 | `mix deps.update`, but will run any associated `upgrade` tasks for the packages that have changed. 12 | 13 | ## Using Upgraders 14 | 15 | In general, you can replace your usage of `mix deps.update` with `mix igniter.upgrade`. Packages that 16 | don't use igniter will be updated as normal, and packages that do will have any associated upgraders run. 17 | 18 | ## Writing Upgraders 19 | 20 | To write an upgrader, your package should provide an igniter task called `your_package.upgrade`. This task 21 | will take two positional arguments, `from` and `to`, which are the old and new versions of the package. 22 | 23 | While you are free to implement this logic however you like, we suggest using 24 | `mix igniter.gen.task your_package.upgrade --upgrade`, and following the patterns that are provided by the generated task. 25 | 26 | ## Limitations 27 | 28 | ### Compile Compatibility 29 | 30 | The new version of the package must be "compile compatible" with your existing code. For this reason, 31 | we encourage library authors to make even _major_ versions compile compatible with previous versions, but 32 | this is not always possible. For those cases, we encourage library authors to provide a version _prior_ 33 | to their breaking changes that includes an upgrader to code that is compatible with the new version. This way, 34 | you can at least instruct users to `mix igniter.upgrade package@that.version` before upgrading to the latest 35 | version. 36 | 37 | ### Path dependencies 38 | 39 | We cannot determine the old version for path dependencies, so currently there is no way to use 40 | them with `mix igniter.upgrade`. We can potentially support this in the future with arguments 41 | like `--old-version- x.y.z`. 42 | 43 | ## Upgrading in CI (i.e Dependabot) 44 | 45 | The flag `--git-ci` is provided to `mix igniter.upgrade` to allow for CI integration. This flag 46 | causes igniter to parse the previous versions from the `mix.lock` file prior to the current pull request. 47 | This limitation does mean that only hex dependencies can be upgraded in this way. 48 | Here is an example set of github action steps that will run `mix igniter.upgrade` and add a commit 49 | for any upgrades. 50 | 51 | ### Limitations 52 | 53 | This example setup does not trigger the github actions on your PR again. This is due to 54 | [intentional limitations of GITHUB_TOKEN]. You can see more here: https://github.com/stefanzweifel/git-auto-commit-action?tab=readme-ov-file#commits-made-by-this-action-do-not-trigger-new-workflow-runs 55 | It is possible to work around this with a "personal access token". If you come up with a nice way 56 | to make the commit trigger another workflow, please let us know 😊. 57 | 58 | ```yml 59 | igniter-upgrade: 60 | name: mix igniter.upgrade 61 | runs-on: ubuntu-latest 62 | if: ${{inputs.igniter-upgrade}} 63 | permissions: 64 | contents: write 65 | steps: 66 | - name: Dependabot metadata 67 | id: dependabot-metadata 68 | uses: dependabot/fetch-metadata@v2 69 | if: github.event.pull_request.user.login == 'dependabot[bot]' 70 | - uses: actions/checkout@v3 71 | with: 72 | fetch-depth: 0 73 | ref: ${{ github.head_ref }} 74 | # This uses a `.tool-version` file for languages 75 | # Feel free to use whatever steps you want to set up elixir 76 | # and run the `igniter.upgrade` mix task. Just use the same flags as shown. 77 | - uses: team-alembic/staple-actions/actions/mix-task@main 78 | with: 79 | task: igniter.upgrade --git-ci 80 | - name: Commit Changes 81 | uses: stefanzweifel/git-auto-commit-action@v5 82 | if: github.event.pull_request.user.login == 'dependabot[bot]' 83 | with: 84 | commit_message: "[dependabot skip] Apply Igniter Upgrades" 85 | commit_user_name: dependabot[bot] 86 | ``` 87 | -------------------------------------------------------------------------------- /.github/workflows/test-projects.yml: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | name: Test Projects 6 | on: 7 | push: 8 | tags: 9 | - "v*" 10 | branches: [main] 11 | jobs: 12 | test-projects: 13 | runs-on: ubuntu-latest 14 | name: ${{matrix.project.org}}/${{matrix.project.name}} - OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | project: [ 19 | { 20 | org: "ash-project", 21 | name: "ash", 22 | test-cmd: "mix test --only igniter", 23 | }, 24 | { 25 | org: "team-alembic", 26 | name: "ash_authentication", 27 | test-cmd: "mix test --only igniter", 28 | postgres: true, 29 | }, 30 | { 31 | org: "team-alembic", 32 | name: "ash_authentication_phoenix", 33 | test-cmd: "mix test --only igniter", 34 | }, 35 | # { 36 | # org: "BeaconCMS", 37 | # name: "beacon", 38 | # test-cmd: "mix test --only igniter", 39 | # }, 40 | { 41 | org: "oban-bg", 42 | name: "oban", 43 | test-cmd: "mix test --only igniter", 44 | }, 45 | { 46 | org: "mishka-group", 47 | name: "mishka_chelekom", 48 | test-cmd: "mix test --only igniter", 49 | }, 50 | ] 51 | otp: ["27.2"] 52 | elixir: ["1.18.1"] 53 | services: 54 | pg: 55 | image: ${{ (matrix.project.postgres) && 'postgres:16' || '' }} 56 | env: 57 | POSTGRES_USER: postgres 58 | POSTGRES_PASSWORD: postgres 59 | POSTGRES_DB: postgres 60 | options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5 61 | ports: ["5432:5432"] 62 | steps: 63 | - uses: actions/checkout@v2 64 | with: 65 | repository: ${{matrix.project.org}}/${{matrix.project.name}} 66 | path: ${{matrix.project.name}} 67 | ref: ${{matrix.project.ref}} 68 | - run: sudo apt-get install --yes libssl-dev 69 | - uses: actions/checkout@v2 70 | with: 71 | repository: ash-project/igniter 72 | path: igniter 73 | - uses: erlef/setup-beam@v1 74 | with: 75 | otp-version: ${{matrix.otp}} 76 | elixir-version: ${{matrix.elixir}} 77 | - uses: actions/cache@v4 78 | id: cache-deps 79 | with: 80 | path: ${{matrix.project.name}}/deps 81 | key: ${{matrix.project.name}}-otp-${{matrix.otp}}-elixir-${{matrix.elixir}}-deps-2-${{ hashFiles('config/**/*.exs') }}-${{ hashFiles(format('{0}{1}', github.workspace, '/ash/mix.lock')) }} 82 | restore-keys: ${{matrix.project.name}}-otp-${{matrix.otp}}-elixir-${{matrix.elixir}}-deps-2-${{ hashFiles('config/**/*.exs') }}- 83 | - uses: actions/cache@v4 84 | id: cache-build 85 | with: 86 | path: ${{matrix.project.name}}/_build 87 | key: ${{matrix.project.name}}-otp-${{matrix.otp}}-elixir-${{matrix.elixir}}-build-3-${{ hashFiles('config/**/*.exs') }}-${{ hashFiles(format('{0}{1}', github.workspace, '/ash/mix.lock')) }} 88 | restore-keys: ${{matrix.project.name}}-otp-${{matrix.otp}}-elixir-${{matrix.elixir}}-build-3-${{ hashFiles('config/**/*.exs') }}- 89 | - run: mix deps.get 90 | working-directory: ./${{matrix.project.name}} 91 | - run: mix archive.install hex igniter_new --force 92 | - run: mix archive.install hex phx_new --force 93 | - run: mix deps.update igniter 94 | working-directory: ./${{matrix.project.name}} 95 | - run: mix igniter.add igniter@path:../igniter --yes 96 | working-directory: ./${{matrix.project.name}} 97 | - run: mix deps.get 98 | working-directory: ./${{matrix.project.name}} 99 | - run: ${{matrix.project.test-cmd}} 100 | if: ${{matrix.project.test-cmd}} 101 | working-directory: ./${{matrix.project.name}} 102 | -------------------------------------------------------------------------------- /lib/igniter/scribe.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Scribe do 6 | @moduledoc """ 7 | Contains functions for use with the `--scribe` option in Igniter. 8 | 9 | See [the guide](/documentation/documenting-tasks.md) for more. 10 | """ 11 | 12 | @doc """ 13 | Sets the path and title of the document being generated. Only the first call to this is honored. 14 | """ 15 | def start_document(igniter, title, contents, opts \\ []) do 16 | if igniter.assigns[:scribe?] do 17 | igniter 18 | |> append_content("# #{title}") 19 | |> append_content(contents) 20 | |> Igniter.assign( 21 | :test_files, 22 | Map.merge(igniter.assigns[:test_files] || %{}, opts[:files] || %{}) 23 | ) 24 | else 25 | igniter 26 | end 27 | end 28 | 29 | @doc """ 30 | Adds a new section to the documentation. 31 | """ 32 | def section(igniter, header, explanation, callback) do 33 | if igniter.assigns[:scribe?] do 34 | current_header = igniter.assigns[:scribe][:header] 35 | nesting_level = igniter.assigns[:scribe][:nesting_level] || 1 36 | 37 | header = String.duplicate("#", nesting_level + 1) <> " " <> header 38 | 39 | igniter 40 | |> assign(:header, header) 41 | |> assign(:nesting_level, nesting_level + 1) 42 | |> append_content(header) 43 | |> append_content(explanation) 44 | |> callback.() 45 | |> assign(:header, current_header) 46 | |> assign(:nesting_level, nesting_level) 47 | else 48 | callback.(igniter) 49 | end 50 | end 51 | 52 | def patch(original_igniter, callback) do 53 | if original_igniter.assigns[:scribe?] do 54 | new_igniter = callback.(original_igniter) 55 | 56 | new_igniter.rewrite 57 | |> Enum.reduce(new_igniter, fn source, igniter -> 58 | existing_source = 59 | Rewrite.source(original_igniter.rewrite, source.path) 60 | 61 | lang = if Path.extname(source.path) in [".ex", ".eex"], do: "elixir", else: nil 62 | 63 | case existing_source do 64 | {:ok, existing_source} -> 65 | if Rewrite.Source.version(existing_source) == Rewrite.Source.version(source) do 66 | igniter 67 | else 68 | TextDiff.format( 69 | existing_source |> Rewrite.Source.get(:content) |> eof_newline(), 70 | source |> Rewrite.Source.get(:content) |> eof_newline(), 71 | color: false, 72 | line_numbers: false, 73 | format: [ 74 | separator: "" 75 | ] 76 | ) 77 | |> IO.iodata_to_binary() 78 | |> String.trim_trailing() 79 | |> String.split("\n") 80 | |> Enum.map_join("\n", fn 81 | " " <> str -> str 82 | other -> other 83 | end) 84 | |> case do 85 | "" -> 86 | igniter 87 | 88 | diff -> 89 | igniter 90 | |> append_content("Update `#{source.path}`:") 91 | |> append_content(""" 92 | ```diff 93 | #{diff} 94 | ``` 95 | """) 96 | end 97 | end 98 | 99 | _ -> 100 | igniter 101 | |> append_content("Create `#{source.path}`:") 102 | |> append_content(""" 103 | ```#{lang} 104 | #{Rewrite.Source.get(source, :content)} 105 | ``` 106 | """) 107 | end 108 | end) 109 | else 110 | callback.(original_igniter) 111 | end 112 | end 113 | 114 | @doc false 115 | def write(igniter, path) do 116 | File.write!(path, igniter.assigns[:scribe][:content]) 117 | 118 | igniter 119 | end 120 | 121 | defp append_content(igniter, nil) do 122 | igniter 123 | end 124 | 125 | defp append_content(igniter, content) do 126 | assign(igniter, :content, (igniter.assigns[:scribe][:content] || "") <> "\n" <> content) 127 | end 128 | 129 | defp assign(igniter, key, value) do 130 | Igniter.assign(igniter, :scribe, Map.put(igniter.assigns[:scribe] || %{}, key, value)) 131 | end 132 | 133 | defp eof_newline(string), do: String.trim_trailing(string) <> "\n" 134 | end 135 | -------------------------------------------------------------------------------- /lib/mix/tasks/igniter.install.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | ignore_module_conflict = Code.get_compiler_option(:ignore_module_conflict) 6 | 7 | Code.put_compiler_option(:ignore_module_conflict, true) 8 | 9 | defmodule Mix.Tasks.Igniter.Install do 10 | @moduledoc """ 11 | Install a package or packages, running any Igniter installers. 12 | 13 | ## Args 14 | 15 | mix igniter.install package1 package2 package3 16 | 17 | ## Package formats 18 | 19 | * `package` - The latest version of the package will be installed, pinned at the 20 | major version, or minor version if there is no major version yet. 21 | * `package@version` - The package will be installed at the specified version. 22 | If the version given is generic, like `3.0`, it will be pinned as described above. 23 | if it is specific, like `3.0.1`, it will be pinned at that *exact* version with `==`. 24 | * `package@git:git_url` - The package will be installed from the specified git url. 25 | * `package@github:project/repo` - The package will be installed from the specified github repo. 26 | * `package@github:project/repo@ref` - The package will be installed from the specified github repo, at the specified ref (i.e tag, branch, commit). 27 | * `package@path:path/to/dep` - The package will be installed from the specified path. 28 | * `org/package` - The package exists in a private Hex organization. This can be used 29 | along with all the options above, e.g. `org/package@version`. 30 | 31 | Additionally, a Git ref can be specified when using `git` or `github`: 32 | 33 | * `package@git:git_url@ref` 34 | 35 | ## Options 36 | 37 | * `--only` - Install the requested packages in only a specific environment(s), i.e `--only dev`, `--only dev,test` 38 | 39 | ## Switches 40 | 41 | * `--dry-run` - Run the task without making any changes. 42 | * `--yes` - Automatically answer yes to any prompts. 43 | * `--yes-to-deps` - Automatically answer yes to any prompts about installing new deps. 44 | * `--verbose` - Display additional output from various operations. 45 | * `--example` - Request that installed packages include initial example code. 46 | 47 | `argv` values are also passed to the igniter installer tasks of installed packages. 48 | """ 49 | 50 | use Mix.Task 51 | 52 | @impl true 53 | @shortdoc "Install a package or packages, and run any associated installers." 54 | def run(argv) do 55 | Igniter.Util.Loading.with_spinner( 56 | "compile", 57 | fn -> 58 | Mix.Task.run("deps.compile") 59 | Mix.Task.run("deps.loadpaths") 60 | Mix.Task.run("compile", ["--no-compile"]) 61 | end, 62 | verbose?: "--verbose" in argv 63 | ) 64 | 65 | argv = Enum.reject(argv, &(&1 in ["--from-igniter-new", "--igniter-repeat"])) 66 | 67 | {argv, positional} = extract_positional_args(argv) 68 | 69 | packages = 70 | positional 71 | |> Enum.join(",") 72 | |> String.split(",", trim: true) 73 | |> Enum.map(&String.trim/1) 74 | 75 | if Enum.empty?(packages) do 76 | raise ArgumentError, "must provide at least one package to install" 77 | end 78 | 79 | Application.ensure_all_started(:rewrite) 80 | 81 | Igniter.Util.Install.install(Enum.join(packages, ","), argv) 82 | end 83 | 84 | @doc false 85 | defp extract_positional_args(argv) do 86 | do_extract_positional_args(argv, [], []) 87 | end 88 | 89 | defp do_extract_positional_args([], argv, positional), do: {argv, positional} 90 | 91 | defp do_extract_positional_args(argv, got_argv, positional) do 92 | case OptionParser.next(argv, switches: []) do 93 | {_, _key, true, rest} -> 94 | do_extract_positional_args( 95 | rest, 96 | got_argv ++ [Enum.at(argv, 0)], 97 | positional 98 | ) 99 | 100 | {_, _key, _value, rest} -> 101 | count_consumed = Enum.count(argv) - Enum.count(rest) 102 | 103 | do_extract_positional_args( 104 | rest, 105 | got_argv ++ Enum.take(argv, count_consumed), 106 | positional 107 | ) 108 | 109 | {:error, rest} -> 110 | [first | rest] = rest 111 | do_extract_positional_args(rest, got_argv, positional ++ [first]) 112 | end 113 | end 114 | end 115 | 116 | Code.put_compiler_option(:ignore_module_conflict, ignore_module_conflict) 117 | -------------------------------------------------------------------------------- /documentation/writing-generators.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # Writing Generators 8 | 9 | In `Igniter`, generators are done as a wrapper around `Mix.Task`, allowing them to be called individually or composed as part of a task. 10 | 11 | Since an example is worth a thousand words, lets take a look at an example that generates a file and ensures a configuration is set in the user's `config.exs`. 12 | 13 | > ### An igniter for igniters?! {: .info} 14 | > 15 | > Run `mix igniter.gen.task your_app.task.name` to generate a new, fully configured igniter task! 16 | 17 | ```elixir 18 | # lib/mix/tasks/your_lib.gen.your_thing.ex 19 | defmodule Mix.Tasks.YourLib.Gen.YourThing do 20 | use Igniter.Mix.Task 21 | 22 | @impl Igniter.Mix.Task 23 | def igniter(igniter) do 24 | [module_name | _] = igniter.args.argv 25 | 26 | module_name = Igniter.Code.Module.parse(module_name) 27 | path = Igniter.Code.Module.proper_location(module_name) 28 | app_name = Igniter.Project.Application.app_name(igniter) 29 | 30 | igniter 31 | |> Igniter.create_new_elixir_file(path, """ 32 | defmodule #{inspect(module_name)} do 33 | use YourLib.Thing 34 | 35 | ...some_code 36 | end 37 | """) 38 | |> Igniter.Project.Config.configure( 39 | "config.exs", 40 | app_name, 41 | [:list_of_things], 42 | [module_name], 43 | updater: &Igniter.Code.List.prepend_new_to_list(&1, module_name) 44 | ) 45 | end 46 | end 47 | ``` 48 | 49 | Now, your users can run 50 | 51 | `mix your_lib.gen.your_thing MyApp.MyModuleName` 52 | 53 | and it will present them with a diff, creating a new file and updating their `config.exs`. 54 | 55 | Additionally, other generators can "compose" this generator using `Igniter.compose_task/3` 56 | 57 | ```elixir 58 | igniter 59 | |> Igniter.compose_task(Mix.Tasks.YourLib.Gen.YourThing, ["MyApp.MyModuleName"]) 60 | |> Igniter.compose_task(Mix.Tasks.YourLib.Gen.YourThing, ["MyApp.SomeOtherName"]) 61 | ``` 62 | 63 | ## Writing a library installer 64 | 65 | Igniter will look for a mix task called `your_library.install` when a user runs `mix igniter.install your_library`. As long as it has the correct name, it will be run automatically as part of installation! 66 | 67 | ## Task Groups 68 | 69 | Igniter allows for _composing_ tasks, which means that many igniter tasks can be run in tandem. This happens automatically when using `mix igniter.install`, for example: 70 | `mix igniter.install package1 package2`. You can also do this manually by using `Igniter.compose_task/3`. See the example above. 71 | 72 | However, composing tasks means that sometimes a flag from one task may conflict with a flag from another task. Igniter will alert users when this happens, and ask them to 73 | prefix the option with your task name. For example, the user may see an error like this: 74 | 75 | ```sh 76 | Ambiguous flag provided `--option`. 77 | 78 | The task or task groups `package1, package2` all define the flag `--option`. 79 | 80 | To disambiguate, provide the arg as `--.option`, 81 | where `` is the task or task group name. 82 | 83 | For example: 84 | 85 | `--package1.option` 86 | ``` 87 | 88 | It is not possible to prevent this from happening for all combinations of invocations of your task, but you can help by using a `group`. 89 | 90 | ```elixir 91 | %Igniter.Mix.Task.Info{ 92 | group: :your_package, 93 | ... 94 | } 95 | ``` 96 | 97 | Setting this group performs two functions: 98 | 99 | 1. any tasks that share a group with each other will be assumed that the same flag has the same meaning. That way, 100 | users don't have to disambiguate when calling `mix igniter.install yourthing1 yourthing2 --option`, because it is assumed 101 | to have the same meaning. 102 | 2. it can provide a shorter/semantic name to type, i.e instead of `--ash-authentication-phoenix.install.domain` it could be just `--ash.domain`. 103 | 104 | By default the group name is the _full task name_. We suggest setting a group for all of your tasks. 105 | You should _not_ use a group name that is used by someone else, just like you should not use a module prefix used by someone else in general. 106 | 107 | ## Navigating the Igniter Codebase 108 | 109 | A large part of writing generators with igniter is leveraging our built-in suite of tools for working with zippers and AST, as well as our off-the-shelf patchers for making project modifications. The codebase is split up into four primary divisions: 110 | 111 | - `Igniter.Project.*` - project-level, off-the-shelf patchers 112 | - `Igniter.Code.*` - working with zippers and manipulating source code 113 | - `Igniter.Mix.*` - mix tasks, tools for writing igniter mix tasks 114 | - `Igniter.Util.*` - various utilities and helpers 115 | -------------------------------------------------------------------------------- /test/igniter/test_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.TestTest do 6 | use ExUnit.Case 7 | import Igniter.Test 8 | 9 | describe "refute_creates/2" do 10 | test "passes when file is not created" do 11 | test_project() 12 | |> refute_creates("lib/non_existent_file.ex") 13 | end 14 | 15 | test "passes when file already exists (not created in this run)" do 16 | test_project() 17 | |> refute_creates("mix.exs") 18 | end 19 | 20 | test "fails when file is created" do 21 | assert_raise ExUnit.AssertionError, 22 | ~r/Expected "lib\/new_file\.ex" to not have been created, but it was/, 23 | fn -> 24 | test_project() 25 | |> Igniter.create_new_file("lib/new_file.ex", "content") 26 | |> refute_creates("lib/new_file.ex") 27 | end 28 | end 29 | 30 | test "returns the igniter unchanged on success" do 31 | igniter = 32 | test_project() 33 | |> Igniter.create_new_file("lib/some_file.ex", "content") 34 | 35 | result = refute_creates(igniter, "lib/non_existent_file.ex") 36 | assert result == igniter 37 | end 38 | end 39 | 40 | describe "assert_creates/3" do 41 | test "passes when file is created" do 42 | test_project() 43 | |> Igniter.create_new_file("lib/new_file.ex", "content") 44 | |> assert_creates("lib/new_file.ex", "content\n") 45 | end 46 | 47 | test "passes when file is created without content validation" do 48 | test_project() 49 | |> Igniter.create_new_file("lib/new_file.ex", "content") 50 | |> assert_creates("lib/new_file.ex") 51 | end 52 | 53 | test "fails when file is not created" do 54 | assert_raise ExUnit.AssertionError, 55 | ~r/Expected "lib\/non_existent\.ex" to have been created, but it was not/, 56 | fn -> 57 | test_project() 58 | |> assert_creates("lib/non_existent.ex") 59 | end 60 | end 61 | 62 | test "fails when file already existed" do 63 | assert_raise ExUnit.AssertionError, 64 | ~r/Expected "mix\.exs" to have been created, but it already existed/, 65 | fn -> 66 | test_project() 67 | |> assert_creates("mix.exs") 68 | end 69 | end 70 | 71 | test "fails when content doesn't match" do 72 | assert_raise ExUnit.AssertionError, 73 | ~r/Expected created file "lib\/new_file\.ex" to have the following contents/, 74 | fn -> 75 | test_project() 76 | |> Igniter.create_new_file("lib/new_file.ex", "actual content") 77 | |> assert_creates("lib/new_file.ex", "expected content\n") 78 | end 79 | end 80 | end 81 | 82 | describe "assert_moves/3" do 83 | test "passes when file is moved" do 84 | test_project(files: %{"old.exs" => "content"}) 85 | |> Igniter.move_file("old.exs", "new.exs") 86 | |> assert_moves("old.exs", "new.exs") 87 | end 88 | 89 | test "fails when file is not moved" do 90 | assert_raise ExUnit.AssertionError, 91 | ~r/Expected \"old.exs\" to have been moved, but it was not.\n\n No files were moved./, 92 | fn -> 93 | test_project(files: %{"old.exs" => "content"}) 94 | |> assert_moves("old.exs", "new.exs") 95 | end 96 | end 97 | 98 | test "fails when different file is moved" do 99 | assert_raise ExUnit.AssertionError, 100 | ~r/Expected \"one.exs\" to have been moved, but it was not.\n\n The following files were moved:\n\n \* two.exs\n ↳ three.exs/, 101 | fn -> 102 | test_project(files: %{"one.exs" => "content", "two.exs" => "content"}) 103 | |> Igniter.move_file("two.exs", "three.exs") 104 | |> assert_moves("one.exs", "three.exs") 105 | end 106 | end 107 | 108 | test "fails when file is not moved to a different location" do 109 | assert_raise ExUnit.AssertionError, 110 | ~r/Expected \"old.exs\" to have been moved to:\n\n new.exs\n\n But it was moved to:\n\n mature.exs/, 111 | fn -> 112 | test_project(files: %{"old.exs" => "content"}) 113 | |> Igniter.move_file("old.exs", "mature.exs") 114 | |> assert_moves("old.exs", "new.exs") 115 | end 116 | end 117 | end 118 | end 119 | -------------------------------------------------------------------------------- /test/igniter/extensions/phoenix_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Extensions.PhoenixTest do 6 | use ExUnit.Case 7 | import Igniter.Test 8 | 9 | describe "proper_location/2" do 10 | test "extensions are honored even if the extension is added in the same check" do 11 | test_project() 12 | |> Igniter.Project.IgniterConfig.add_extension(Igniter.Extensions.Phoenix) 13 | |> Igniter.Project.Module.create_module(TestWeb.FooController, """ 14 | use TestWeb, :controller 15 | """) 16 | |> assert_creates("lib/test_web/controllers/foo_controller.ex") 17 | end 18 | 19 | test "returns a controller location" do 20 | igniter = 21 | test_project() 22 | |> Igniter.create_new_file("lib/test_web/controllers/foo_controller.ex", """ 23 | defmodule TestWeb.FooController do 24 | use TestWeb, :controller 25 | 26 | end 27 | """) 28 | 29 | assert {:ok, "test_web/controllers/foo_controller.ex"} = 30 | Igniter.Extensions.Phoenix.proper_location(igniter, TestWeb.FooController, []) 31 | end 32 | 33 | test "when belonging to a controller, it returns an html location" do 34 | igniter = 35 | test_project() 36 | |> Igniter.create_new_file("lib/test_web/controllers/foo_controller.ex", """ 37 | defmodule TestWeb.FooController do 38 | use TestWeb, :controller 39 | 40 | end 41 | """) 42 | |> Igniter.create_new_file("lib/test_web/controllers/foo_html.ex", """ 43 | defmodule TestWeb.FooHTML do 44 | use TestWeb, :html 45 | end 46 | """) 47 | 48 | assert {:ok, "test_web/controllers/foo_html.ex"} = 49 | Igniter.Extensions.Phoenix.proper_location(igniter, TestWeb.FooHTML, []) 50 | end 51 | 52 | test "when not belonging to a controller, we say we don't know where it goes" do 53 | igniter = 54 | test_project() 55 | |> Igniter.create_new_file("lib/test_web/controllers/foo_html.ex", """ 56 | defmodule TestWeb.FooHTML do 57 | use TestWeb, :html 58 | end 59 | """) 60 | 61 | assert :error = 62 | Igniter.Extensions.Phoenix.proper_location(igniter, TestWeb.FooHTML, []) 63 | end 64 | 65 | test "returns a json location" do 66 | igniter = 67 | test_project() 68 | |> Igniter.create_new_file("test_web/controllers/foo_controller.ex", """ 69 | defmodule TestWeb.FooController do 70 | use TestWeb, :controller 71 | 72 | end 73 | """) 74 | |> Igniter.create_new_file("lib/test_web/controllers/foo_json.ex", """ 75 | defmodule TestWeb.FooJSON do 76 | 77 | def render(_), do: %{foo: "bar"} 78 | end 79 | """) 80 | 81 | assert Igniter.Extensions.Phoenix.proper_location(igniter, TestWeb.FooJSON, []) == 82 | {:ok, "test_web/controllers/foo_json.ex"} 83 | end 84 | end 85 | 86 | describe "Live namespace handling" do 87 | test "does not duplicate 'live' directory for modules with Live namespace segment" do 88 | igniter = 89 | test_project() 90 | |> Igniter.Project.IgniterConfig.add_extension(Igniter.Extensions.Phoenix) 91 | 92 | module_name = MyApp.Live.Dashboard.TestLive 93 | 94 | igniter = 95 | Igniter.Project.Module.create_module(igniter, module_name, """ 96 | @moduledoc "Test module" 97 | def hello, do: :world 98 | """) 99 | 100 | {:ok, {_igniter, source, _zipper}} = 101 | Igniter.Project.Module.find_module(igniter, module_name) 102 | 103 | actual_path = Rewrite.Source.get(source, :path) 104 | 105 | refute actual_path =~ ~r/live\/live/, 106 | "Module path should not contain duplicate 'live/live' directories. Got: #{actual_path}" 107 | 108 | assert actual_path == "lib/my_app/live/dashboard/test_live.ex" 109 | end 110 | 111 | test "correctly handles LiveView modules with Web prefix" do 112 | igniter = 113 | test_project() 114 | |> Igniter.Project.IgniterConfig.add_extension(Igniter.Extensions.Phoenix) 115 | 116 | module_name = TestWeb.DashboardLive 117 | 118 | igniter = 119 | Igniter.Project.Module.create_module(igniter, module_name, """ 120 | use TestWeb, :live_view 121 | 122 | def render(assigns) do 123 | ~H"
Test
" 124 | end 125 | """) 126 | 127 | {:ok, {_igniter, source, _zipper}} = 128 | Igniter.Project.Module.find_module(igniter, module_name) 129 | 130 | actual_path = Rewrite.Source.get(source, :path) 131 | 132 | assert actual_path == "lib/test_web/live/dashboard_live.ex" 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /test/igniter/code/module_test.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Code.ModuleTest do 6 | use ExUnit.Case 7 | 8 | import Igniter.Test 9 | 10 | doctest Igniter.Code.Module 11 | 12 | test "modules will be moved according to config" do 13 | %{rewrite: rewrite} = 14 | Igniter.new() 15 | |> Igniter.assign(:igniter_exs, 16 | module_location: :inside_matching_folder 17 | ) 18 | |> Igniter.include_or_create_file("lib/foo/bar.ex", "defmodule Foo.Bar do\nend") 19 | |> Igniter.include_or_create_file( 20 | "lib/foo/bar/baz.ex", 21 | "defmodule Foo.Bar.Baz do\nend" 22 | ) 23 | |> Igniter.prepare_for_write() 24 | 25 | paths = Rewrite.paths(rewrite) 26 | 27 | assert "lib/foo/bar/bar.ex" in paths 28 | assert "lib/foo/bar/baz.ex" in paths 29 | end 30 | 31 | test "modules can be found anywhere across the project" do 32 | %{rewrite: rewrite} = 33 | Igniter.new() 34 | |> Igniter.create_new_file("lib/foo/bar.ex", """ 35 | defmodule Foo.Bar do 36 | defmodule Baz do 37 | 10 38 | end 39 | end 40 | """) 41 | |> Igniter.Project.Module.find_and_update_or_create_module( 42 | Foo.Bar.Baz, 43 | """ 44 | 20 45 | """, 46 | fn zipper -> 47 | {:ok, Igniter.Code.Common.replace_code(zipper, 30)} 48 | end 49 | ) 50 | 51 | contents = 52 | rewrite 53 | |> Rewrite.source!("lib/foo/bar.ex") 54 | |> Rewrite.Source.get(:content) 55 | 56 | assert contents == """ 57 | defmodule Foo.Bar do 58 | defmodule Baz do 59 | 30 60 | end 61 | end 62 | """ 63 | end 64 | 65 | test "modules will be created if they do not exist, in the conventional place" do 66 | %{rewrite: rewrite} = 67 | Igniter.new() 68 | |> Igniter.create_new_file("lib/foo/bar.ex", """ 69 | defmodule Foo.Bar do 70 | end 71 | """) 72 | |> Igniter.Project.Module.find_and_update_or_create_module( 73 | Foo.Bar.Baz, 74 | """ 75 | 20 76 | """, 77 | fn zipper -> 78 | {:ok, Igniter.Code.Common.replace_code(zipper, 30)} 79 | end 80 | ) 81 | 82 | contents = 83 | rewrite 84 | |> Rewrite.source!("lib/foo/bar/baz.ex") 85 | |> Rewrite.Source.get(:content) 86 | 87 | assert contents == """ 88 | defmodule Foo.Bar.Baz do 89 | 20 90 | end 91 | """ 92 | end 93 | 94 | test "modules will be created if they do not exist, in the conventional place, which can be configured" do 95 | %{rewrite: rewrite} = 96 | Igniter.new() 97 | |> Igniter.assign(:igniter_exs, 98 | module_location: :inside_matching_folder 99 | ) 100 | |> Igniter.create_new_file("lib/foo/bar/something.ex", """ 101 | defmodule Foo.Bar.Something do 102 | end 103 | """) 104 | |> Igniter.Project.Module.find_and_update_or_create_module( 105 | Foo.Bar, 106 | """ 107 | 20 108 | """, 109 | fn zipper -> 110 | {:ok, Igniter.Code.Common.replace_code(zipper, 30)} 111 | end 112 | ) 113 | |> Igniter.prepare_for_write() 114 | 115 | contents = 116 | rewrite 117 | |> Rewrite.source!("lib/foo/bar/bar.ex") 118 | |> Rewrite.Source.get(:content) 119 | 120 | assert contents == """ 121 | defmodule Foo.Bar do 122 | 20 123 | end 124 | """ 125 | end 126 | 127 | describe inspect(&Igniter.Code.Module.find_all_matching_modules/1) do 128 | test "finds all elixir files but ignores all other files" do 129 | igniter = 130 | test_project() 131 | |> Igniter.Project.Module.create_module(Foo, """ 132 | defmodule Foo do 133 | end 134 | """) 135 | |> Igniter.create_new_file("test.txt", "Foo") 136 | 137 | assert {_igniter, [Foo, Test, Test.MixProject, TestTest]} = 138 | Igniter.Project.Module.find_all_matching_modules(igniter, fn _module, _zipper -> 139 | true 140 | end) 141 | end 142 | end 143 | 144 | test "move_to_attribute_definition" do 145 | mod_zipper = 146 | ~s""" 147 | defmodule MyApp.Foo do 148 | @doc "My app module doc" 149 | @foo_key Application.compile_env!(:my_app, :key) 150 | end 151 | """ 152 | |> Sourceror.parse_string!() 153 | |> Sourceror.Zipper.zip() 154 | 155 | assert {:ok, zipper} = Igniter.Code.Module.move_to_attribute_definition(mod_zipper, :doc) 156 | assert Igniter.Util.Debug.code_at_node(zipper) == ~s|@doc "My app module doc"| 157 | 158 | assert {:ok, zipper} = Igniter.Code.Module.move_to_attribute_definition(mod_zipper, :foo_key) 159 | 160 | assert Igniter.Util.Debug.code_at_node(zipper) == 161 | "@foo_key Application.compile_env!(:my_app, :key)" 162 | end 163 | end 164 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.MixProject do 6 | use Mix.Project 7 | 8 | @version "0.7.0" 9 | @install_version "~> 0.6" 10 | 11 | @description """ 12 | A code generation and project patching framework 13 | """ 14 | 15 | def project do 16 | [ 17 | app: :igniter, 18 | version: @version, 19 | elixir: "~> 1.15", 20 | start_permanent: Mix.env() == :prod, 21 | elixirc_paths: elixirc_paths(Mix.env()), 22 | description: @description, 23 | aliases: aliases(), 24 | package: package(), 25 | docs: docs(), 26 | deps: deps(), 27 | dialyzer: [ 28 | plt_add_apps: [:mix, :hex, :ex_unit] 29 | ] 30 | ] 31 | end 32 | 33 | defp elixirc_paths(:test) do 34 | elixirc_paths(:dev) ++ ["test/support"] 35 | end 36 | 37 | defp elixirc_paths(_env) do 38 | ["lib"] 39 | end 40 | 41 | # Run "mix help compile.app" to learn about applications. 42 | def application do 43 | [ 44 | # if you change this, change it in the installer archive too. 45 | extra_applications: [:logger, :public_key, :ssl, :inets, :eex] 46 | ] 47 | end 48 | 49 | defp docs do 50 | [ 51 | main: "readme", 52 | source_ref: "v#{@version}", 53 | source_url: "https://github.com/ash-project/igniter", 54 | logo: "logos/igniter-logo-small.png", 55 | extra_section: "GUIDES", 56 | extras: [ 57 | {"README.md", title: "Home"}, 58 | "documentation/writing-generators.md", 59 | "documentation/configuring-igniter.md", 60 | "documentation/documenting-tasks.md", 61 | "documentation/upgrades.md", 62 | "CHANGELOG.md" 63 | ], 64 | groups_for_modules: [ 65 | "Writing Mix tasks": [~r"Igniter\.Mix\..*"], 66 | "Project modifications": [~r"Igniter\.Refactors\..*", ~r"Igniter\.Project\..*"], 67 | "Code modifications": [~r"Igniter\.Code\..*"], 68 | Extensions: [Igniter.Extension, ~r"Igniter\.Extensions\..*"], 69 | "Library support": [~r"Igniter\.Libs\..*"], 70 | Utilities: [~r"Igniter\.Util\..*"] 71 | ], 72 | before_closing_head_tag: fn type -> 73 | if type == :html do 74 | """ 75 | 84 | """ 85 | end 86 | end 87 | ] 88 | end 89 | 90 | defp package do 91 | [ 92 | maintainers: [ 93 | "Zach Daniel " 94 | ], 95 | licenses: ["MIT"], 96 | files: ~w(lib .formatter.exs mix.exs README* LICENSE* 97 | CHANGELOG* usage-rules.md), 98 | links: %{ 99 | "GitHub" => "https://github.com/ash-project/igniter", 100 | "Changelog" => "https://github.com/ash-project/igniter/blob/main/CHANGELOG.md", 101 | "Discord" => "https://discord.gg/HTHRaaVPUc", 102 | "Website" => "https://ash-hq.org", 103 | "Forum" => "https://elixirforum.com/c/ash-framework-forum/", 104 | "REUSE Compliance" => "https://api.reuse.software/info/github.com/ash-project/igniter" 105 | } 106 | ] 107 | end 108 | 109 | # Run "mix help deps" to learn about dependencies. 110 | defp deps do 111 | [ 112 | {:rewrite, "~> 1.1 and >= 1.1.1"}, 113 | {:glob_ex, "~> 0.1.7"}, 114 | {:spitfire, "~> 0.1 and >= 0.1.3"}, 115 | {:sourceror, "~> 1.4"}, 116 | {:jason, "~> 1.4"}, 117 | {:req, "~> 0.5"}, 118 | {:phx_new, "~> 1.7", optional: true}, 119 | {:owl, "~> 0.11"}, 120 | # Dev/Test dependencies 121 | {:eflame, "~> 1.0", only: [:dev, :test]}, 122 | {:ex_doc, "~> 0.32", only: [:dev, :test], runtime: false}, 123 | {:ex_check, "~> 0.12", only: [:dev, :test]}, 124 | {:credo, ">= 0.0.0", only: [:dev, :test], runtime: false}, 125 | {:dialyxir, ">= 0.0.0", only: [:dev, :test], runtime: false}, 126 | {:mimic, "~> 2.0", only: [:test]}, 127 | {:git_ops, github: "zachdaniel/git_ops", branch: "no-igniter", only: :dev}, 128 | {:mix_audit, ">= 0.0.0", only: [:dev, :test], runtime: false}, 129 | {:benchee, "~> 1.1", only: [:dev, :test]}, 130 | {:doctor, "~> 0.21", only: [:dev, :test]} 131 | ] 132 | end 133 | 134 | defp aliases do 135 | [ 136 | credo: "credo --strict", 137 | "archive.build": &raise_on_archive_build/1 138 | ] 139 | end 140 | 141 | @doc false 142 | def install_version, do: @install_version 143 | 144 | defp raise_on_archive_build(_) do 145 | Mix.raise(""" 146 | You are trying to install "igniter" as an archive, which is not supported. \ 147 | You probably meant to install "igniter_new" instead. 148 | """) 149 | end 150 | end 151 | -------------------------------------------------------------------------------- /lib/igniter/copied_tasks.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.CopiedTasks do 6 | @moduledoc false 7 | 8 | def upgrade_switches do 9 | [ 10 | yes: :boolean, 11 | yes_to_deps: :boolean, 12 | all: :boolean, 13 | only: :string, 14 | target: :string, 15 | verbose: :boolean, 16 | no_archives_check: :boolean, 17 | git_ci: :boolean 18 | ] 19 | end 20 | 21 | def upgrade(original_argv) do 22 | {argv, positional} = extract_positional_args(original_argv) 23 | {opts, _} = OptionParser.parse!(argv, switches: upgrade_switches(), aliases: []) 24 | 25 | Igniter.new() 26 | |> Map.put(:args, %Igniter.Mix.Task.Args{ 27 | positional: %{packages: positional}, 28 | argv: original_argv, 29 | argv_flags: argv, 30 | options: opts 31 | }) 32 | |> Igniter.Upgrades.upgrade() 33 | end 34 | 35 | def apply_upgrades(original_argv) do 36 | {argv, positional} = extract_positional_args(original_argv) 37 | {opts, _} = OptionParser.parse!(argv, switches: [yes: :boolean], aliases: []) 38 | 39 | Igniter.new() 40 | |> Map.put(:args, %Igniter.Mix.Task.Args{ 41 | positional: %{packages: positional}, 42 | argv: original_argv, 43 | argv_flags: argv, 44 | options: opts 45 | }) 46 | |> do_apply_upgrades() 47 | end 48 | 49 | def do_apply_upgrades(igniter) do 50 | packages = igniter.args.positional.packages 51 | 52 | Enum.reduce(packages, igniter, fn package, igniter -> 53 | case String.split(package, ":", parts: 3, trim: true) do 54 | [name, from, to] -> 55 | task_name = 56 | if name == "igniter" do 57 | "igniter.upgrade_igniter" 58 | else 59 | "#{name}.upgrade" 60 | end 61 | 62 | Igniter.compose_task(igniter, task_name, [from, to] ++ igniter.args.argv_flags) 63 | 64 | _ -> 65 | Mix.raise("Invalid package format: #{package}") 66 | end 67 | end) 68 | end 69 | 70 | def add(argv) do 71 | {argv, positional} = extract_positional_args(argv) 72 | 73 | packages = 74 | positional 75 | |> Enum.join(",") 76 | |> String.split(",", trim: true) 77 | 78 | if Enum.empty?(packages) do 79 | raise ArgumentError, "must provide at least one package to install" 80 | end 81 | 82 | opts = opts(argv) 83 | 84 | igniter = Igniter.new() 85 | 86 | packages 87 | |> Enum.join(",") 88 | |> String.split(",") 89 | |> Enum.reduce(igniter, fn dep, igniter -> 90 | {name, version} = Igniter.Project.Deps.determine_dep_type_and_version!(dep) 91 | Igniter.Project.Deps.add_dep(igniter, {name, version}, yes?: igniter.args.options[:yes]) 92 | end) 93 | |> Igniter.add_task("deps.get") 94 | |> Igniter.do_or_dry_run(opts) 95 | end 96 | 97 | def remove(argv) do 98 | {argv, positional} = extract_positional_args(argv) 99 | 100 | packages = 101 | positional 102 | |> Enum.join(",") 103 | |> String.split(",", trim: true) 104 | 105 | if Enum.empty?(packages) do 106 | raise ArgumentError, "must provide at least one package to remove" 107 | end 108 | 109 | opts = opts(argv) 110 | 111 | igniter = Igniter.new() 112 | 113 | packages 114 | |> Enum.join(",") 115 | |> String.split(",") 116 | |> Enum.map(&String.to_atom/1) 117 | |> Enum.reduce(igniter, fn name, igniter -> 118 | Igniter.Project.Deps.remove_dep(igniter, name) 119 | end) 120 | |> Igniter.add_task("deps.clean", ["--unlock", "--unused"]) 121 | |> Igniter.do_or_dry_run(opts) 122 | end 123 | 124 | @doc false 125 | def install(argv) do 126 | {argv, positional} = extract_positional_args(argv) 127 | 128 | packages = 129 | positional 130 | |> Enum.join(",") 131 | |> String.split(",", trim: true) 132 | 133 | if Enum.empty?(packages) do 134 | raise ArgumentError, "must provide at least one package to install" 135 | end 136 | 137 | Igniter.Util.Install.install(packages, argv) 138 | end 139 | 140 | defp opts(argv) do 141 | yes = "--yes" in argv 142 | [yes: yes, yes_to_deps: yes] 143 | end 144 | 145 | @doc false 146 | defp extract_positional_args(argv) do 147 | do_extract_positional_args(argv, [], []) 148 | end 149 | 150 | defp do_extract_positional_args([], argv, positional), do: {argv, positional} 151 | 152 | defp do_extract_positional_args(argv, got_argv, positional) do 153 | case OptionParser.next(argv, switches: []) do 154 | {_, _key, true, rest} -> 155 | do_extract_positional_args( 156 | rest, 157 | got_argv ++ [Enum.at(argv, 0)], 158 | positional 159 | ) 160 | 161 | {_, _key, _value, rest} -> 162 | count_consumed = Enum.count(argv) - Enum.count(rest) 163 | 164 | do_extract_positional_args( 165 | rest, 166 | got_argv ++ Enum.take(argv, count_consumed), 167 | positional 168 | ) 169 | 170 | {:error, rest} -> 171 | [first | rest] = rest 172 | do_extract_positional_args(rest, got_argv, positional ++ [first]) 173 | end 174 | end 175 | end 176 | -------------------------------------------------------------------------------- /lib/igniter/libs/ecto.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Libs.Ecto do 6 | @moduledoc "Codemods & utilities for working with Ecto" 7 | 8 | import Macro, only: [camelize: 1, underscore: 1] 9 | 10 | @known_repos [ 11 | Ecto.Repo, 12 | AshPostgres.Repo, 13 | AshSqlite.Repo, 14 | AshMysql.Repo 15 | ] 16 | 17 | @doc """ 18 | Generates a new migration file for the given repo. 19 | 20 | ## Options 21 | 22 | - `:body` - the body of the migration 23 | - `:timestamp` - the timestamp to use for the migration. 24 | Primarily useful for testing so you know what the filename will be. 25 | - `:on_exists` - what to do if the migration *module* already exists. Options are: 26 | - `:increment` - Calls this function again, but with an increasing number at the end, until it finds a free name. (default) 27 | - `:skip` - do nothing 28 | - `:overwrite` - overwrites the file 29 | - `{:error, error}` - adds an issue to the igniter that prevents writing and displays to the user 30 | - `{:warning, warning}` - adds a warning to the igniter that allows writing but displays to the user 31 | """ 32 | @spec gen_migration(Igniter.t(), repo :: module(), name :: String.t(), opts :: Keyword.t()) :: 33 | Igniter.t() 34 | def gen_migration(igniter, repo, name, opts \\ []) do 35 | # getting the repos configuration will be a bit harder here 36 | # we'd need to look in config files or in the repos init or in the repo function? 37 | # not worth it for now 38 | path = 39 | Path.join( 40 | "priv/#{repo |> Module.split() |> List.last() |> Macro.underscore()}", 41 | "migrations" 42 | ) 43 | 44 | base_name = "#{underscore(name)}.exs" 45 | file = Path.join(path, "#{opts[:timestamp] || timestamp()}_#{base_name}") 46 | 47 | igniter = Igniter.include_glob(igniter, Path.join(path, "**/*.exs")) 48 | 49 | body = 50 | opts[:body] || 51 | """ 52 | def change do 53 | # your migration here 54 | end 55 | """ 56 | 57 | module = Module.concat([repo, Migrations, camelize(name)]) 58 | 59 | case Igniter.Project.Module.module_exists(igniter, module) do 60 | {true, igniter} -> 61 | case Keyword.get(opts, :on_exists, :increment) do 62 | :skip -> 63 | igniter 64 | 65 | :increment -> 66 | name 67 | |> String.split("_", trim: true) 68 | |> List.last() 69 | |> Integer.parse() 70 | |> case do 71 | {integer, ""} when is_integer(integer) -> 72 | gen_migration(igniter, repo, name <> "_#{integer + 1}", opts) 73 | 74 | _ -> 75 | gen_migration(igniter, repo, name <> "_1", opts) 76 | end 77 | 78 | :overwrite -> 79 | Igniter.create_new_file( 80 | igniter, 81 | file, 82 | """ 83 | defmodule #{inspect(module)} do 84 | use Ecto.Migration 85 | 86 | #{body} 87 | end 88 | """, 89 | on_exists: :overwrite 90 | ) 91 | 92 | {:error, error} -> 93 | Igniter.add_issue(igniter, error) 94 | 95 | {:warning, error} -> 96 | Igniter.add_warning(igniter, error) 97 | end 98 | 99 | {false, igniter} -> 100 | Igniter.create_new_file(igniter, file, """ 101 | defmodule #{inspect(module)} do 102 | use Ecto.Migration 103 | 104 | #{body} 105 | end 106 | """) 107 | end 108 | end 109 | 110 | @doc """ 111 | Selects a repo module from the list of available repos. 112 | 113 | ## Options 114 | 115 | * `:label` - The label to display to the user when selecting the repo 116 | """ 117 | @spec select_repo(Igniter.t(), Keyword.t()) :: {Igniter.t(), nil | module()} 118 | def select_repo(igniter, opts \\ []) do 119 | label = Keyword.get(opts, :label, "Which repo should be used?") 120 | 121 | case list_repos(igniter) do 122 | {igniter, []} -> 123 | {igniter, nil} 124 | 125 | {igniter, [repo]} -> 126 | {igniter, repo} 127 | 128 | {igniter, repos} -> 129 | {igniter, Igniter.Util.IO.select(label, repos, display: &inspect/1)} 130 | end 131 | end 132 | 133 | @doc "Lists all the ecto repos in the project" 134 | @spec list_repos(Igniter.t()) :: {Igniter.t(), [module()]} 135 | def list_repos(igniter) do 136 | Igniter.Project.Module.find_all_matching_modules(igniter, fn _mod, zipper -> 137 | move_to_repo_use(zipper) != :error 138 | end) 139 | end 140 | 141 | defp move_to_repo_use(zipper) do 142 | Igniter.Code.Function.move_to_function_call(zipper, :use, [1, 2], fn zipper -> 143 | Enum.any?(@known_repos, fn repo -> 144 | Igniter.Code.Function.argument_equals?( 145 | zipper, 146 | 0, 147 | repo 148 | ) 149 | end) 150 | end) 151 | end 152 | 153 | defp timestamp do 154 | {{y, m, d}, {hh, mm, ss}} = :calendar.universal_time() 155 | "#{y}#{pad(m)}#{pad(d)}#{pad(hh)}#{pad(mm)}#{pad(ss)}" 156 | end 157 | 158 | defp pad(i) when i < 10, do: <> 159 | defp pad(i), do: to_string(i) 160 | end 161 | -------------------------------------------------------------------------------- /lib/igniter/inflex.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Inflex do 6 | # Copy of Inflex.Pluralize, with fixes for OTP/28 from @warmwaffles 7 | # From https://github.com/warmwaffles/inflex/commit/cc8b7d32d2e9563d3c8ff92c4a85c8eb7c94b9d4 8 | 9 | @moduledoc false 10 | @default true 11 | 12 | @uncountable [ 13 | "aircraft", 14 | "bellows", 15 | "bison", 16 | "deer", 17 | "equipment", 18 | "fish", 19 | "hovercraft", 20 | "information", 21 | "jeans", 22 | "means", 23 | "measles", 24 | "money", 25 | "moose", 26 | "news", 27 | "pants", 28 | "police", 29 | "rice", 30 | "series", 31 | "sheep", 32 | "spacecraft", 33 | "species", 34 | "swine", 35 | "tights", 36 | "tongs", 37 | "trousers" 38 | ] 39 | 40 | def singularize(word) when is_atom(word) do 41 | find_match(singular_regexes(), to_string(word)) 42 | end 43 | 44 | def singularize(word), do: find_match(singular_regexes(), word) 45 | 46 | def pluralize(word) when is_atom(word) do 47 | find_match(plural_regexes(), to_string(word)) 48 | end 49 | 50 | def pluralize(word), do: find_match(plural_regexes(), word) 51 | 52 | def inflect(word, n) when n == 1, do: singularize(word) 53 | def inflect(word, n) when is_number(n), do: pluralize(word) 54 | 55 | defp find_match(set, word) do 56 | cond do 57 | uncountable?(word) -> word 58 | @default -> replace_match(set, word) 59 | end 60 | end 61 | 62 | defp replace_match(set, word) do 63 | find_in_set(set, word) |> replace(word) 64 | end 65 | 66 | defp find_in_set(set, word) do 67 | Enum.find(set, fn {reg, _} -> Regex.match?(reg, word) end) 68 | end 69 | 70 | defp replace({regex, replacement}, word) do 71 | Regex.replace(regex, word, replacement) 72 | end 73 | 74 | defp replace(_, word), do: word 75 | 76 | defp uncountable?(word), do: Enum.member?(@uncountable, word) 77 | 78 | defp singular_regexes do 79 | [ 80 | {~r/(alumn|cact|fung|radi|stimul|syllab)i/i, "\\1us"}, 81 | {~r/(alg|antenn|amoeb|larv|vertebr)ae/i, "\\1a"}, 82 | {~r/^(gen)era$/i, "\\1us"}, 83 | {~r/(pe)ople/i, "\\1rson"}, 84 | {~r/^(zombie)s$/i, "\\1"}, 85 | {~r/^(movie)s$/i, "\\1"}, 86 | {~r/^(drive)s$/i, "\\1"}, 87 | {~r/(g)eese/i, "\\1oose"}, 88 | {~r/(criteri)a/i, "\\1on"}, 89 | {~r/^(m)en$/i, "\\1an"}, 90 | {~r/^(echo)es/i, "\\1"}, 91 | {~r/^(hero)es/i, "\\1"}, 92 | {~r/^(potato)es/i, "\\1"}, 93 | {~r/^(tomato)es/i, "\\1"}, 94 | {~r/^(t)eeth/i, "\\1ooth"}, 95 | {~r/^(l)ice$/i, "\\1ouse"}, 96 | {~r/^(addend|bacteri|curricul|dat|memorand|quant)a$/i, "\\1um"}, 97 | {~r/^(di)ce/i, "\\1e"}, 98 | {~r/^(f)eet/i, "\\1oot"}, 99 | {~r/^(phenomen)a/i, "\\1on"}, 100 | {~r/(child)ren/i, "\\1"}, 101 | {~r/(wo|sea)men$/i, "\\1man"}, 102 | {~r/^(m|l)ice$/i, "\\1ouse"}, 103 | {~r/(bus|canvas|status|alias)(es)?$/i, "\\1"}, 104 | {~r/(ss)$/i, "\\1"}, 105 | {~r/(database)s$/i, "\\1"}, 106 | {~r/([ti])a$/i, "\\1um"}, 107 | {~r/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)(sis|ses)$/i, "\\1sis"}, 108 | {~r/(analy)(sis|ses)$/i, "\\1sis"}, 109 | {~r/(octop|vir)i$/i, "\\1us"}, 110 | {~r/(hive)s$/i, "\\1"}, 111 | {~r/(tive)s$/i, "\\1"}, 112 | {~r/(er)ves$/i, "\\1ve"}, 113 | {~r/([lora])ves$/i, "\\1f"}, 114 | {~r/([^f])ves$/i, "\\1fe"}, 115 | {~r/([^aeiouy]|qu)ies$/i, "\\1y"}, 116 | {~r/(m)ovies$/i, "\\1ovie"}, 117 | {~r/(x|ch|ss|sh)es$/i, "\\1"}, 118 | {~r/(shoe)s$/i, "\\1"}, 119 | {~r/(o)es$/i, "\\1"}, 120 | {~r/s$/i, ""} 121 | ] 122 | end 123 | 124 | defp plural_regexes do 125 | [ 126 | {~r/(alumn|cact|fung|radi|stimul|syllab)us/i, "\\1i"}, 127 | {~r/(alg|antenn|amoeb|larv|vertebr)a/i, "\\1ae"}, 128 | {~r/^(gen)us$/i, "\\1era"}, 129 | {~r/(pe)rson$/i, "\\1ople"}, 130 | {~r/^(zombie)s$/i, "\\1"}, 131 | {~r/^(movie)s$/i, "\\1"}, 132 | {~r/(g)oose$/i, "\\1eese"}, 133 | {~r/(criteri)on/i, "\\1a"}, 134 | {~r/^(men)$/i, "\\1"}, 135 | {~r/^(women)/i, "\\1"}, 136 | {~r/^(echo)$/i, "\\1es"}, 137 | {~r/^(hero)$/i, "\\1es"}, 138 | {~r/^(potato)/i, "\\1es"}, 139 | {~r/^(tomato)/i, "\\1es"}, 140 | {~r/^(t)ooth$/i, "\\1eeth"}, 141 | {~r/^(l)ouse$/i, "\\1ice"}, 142 | {~r/^(addend|bacteri|curricul|dat|memorand|quant)um$/i, "\\1a"}, 143 | {~r/^(di)e$/i, "\\1ce"}, 144 | {~r/^(f)oot$/i, "\\1eet"}, 145 | {~r/^(phenomen)on/i, "\\1a"}, 146 | {~r/(child)$/i, "\\1ren"}, 147 | {~r/(m)an$/i, "\\1en"}, 148 | {~r/(m|l)ouse/i, "\\1ice"}, 149 | {~r/(database)s$/i, "\\1"}, 150 | {~r/(quiz)$/i, "\\1zes"}, 151 | {~r/^(ox)$/i, "\\1en"}, 152 | {~r/(matr|vert|ind)ix|ex$/i, "\\1ices"}, 153 | {~r/(x|ch|ss|sh)$/i, "\\1es"}, 154 | {~r/([^aeiouy]|qu)y$/i, "\\1ies"}, 155 | {~r/(hive)$/i, "\\1s"}, 156 | {~r/(sc[au]rf)$/i, "\\1s"}, 157 | {~r/(?:([^f])fe|((hoo)|([lra]))f)$/i, "\\2\\1ves"}, 158 | {~r/sis$/i, "ses"}, 159 | {~r/([ti])um$/i, "\\1a"}, 160 | {~r/(buffal|tomat)o$/i, "\\1oes"}, 161 | {~r/(octop|vir)us$/i, "\\1i"}, 162 | {~r/(bus|alias|status|canvas)$/i, "\\1es"}, 163 | {~r/(ax|test)is$/i, "\\1es"}, 164 | {~r/s$/i, "s"}, 165 | {~r/data$/i, "data"}, 166 | {~r/$/i, "s"} 167 | ] 168 | end 169 | end 170 | -------------------------------------------------------------------------------- /lib/mix/task/info.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Mix.Task.Info do 6 | @moduledoc """ 7 | Info for an `Igniter.Mix.Task`, returned from the `info/2` callback 8 | 9 | ## Configurable Keys 10 | 11 | * `schema` - The option schema for this task, in the format given to `OptionParser`, i.e `[name: :string]`. See the schema section for more. 12 | * `defaults` - Default values for options in the schema. 13 | * `required` - A list of flags that are required for this task to run. 14 | * `positional` - A list of positional arguments that this task accepts. A list of atoms, or a keyword list with the option and config. 15 | See the positional arguments section for more. 16 | * `aliases` - A map of aliases to the schema keys. 17 | * `only` - For installers, a list of environments that the dependency should be installed to. 18 | * `dep_opts` - For installers, dependency options that should be set, like `runtime: false`. Use the `only` key for `only` option. 19 | * `composes` - A list of tasks that this task might compose. 20 | * `installs` - A list of dependencies that should be installed before continuing. 21 | * `adds_deps` - A list of dependencies that should be added to the `mix.exs`, but do not need to be installed before continuing. 22 | * `extra_args?` - Whether or not to allow extra arguments. This forces all tasks that compose this task to allow extra args as well. 23 | * `example` - An example usage of the task. This is used in the help output. 24 | 25 | Your task should *always* use `switches` and not `strict` to validate provided options! 26 | 27 | ## Options and Arguments 28 | 29 | Command line args are automatically parsed into `igniter.args` using this struct's configuration. 30 | 31 | @impl Igniter.Mix.Task 32 | def igniter(igniter) do 33 | positional = igniter.args.positional 34 | options = igniter.args.options 35 | end 36 | 37 | If you need to do custom validation or parsing, you can implement `c:Igniter.Mix.Task.parse_argv/1` 38 | and return an `Igniter.Mix.Task.Args` struct. If helpful, the `positional_args!/1` and 39 | `options!/1` macros can be used to parse positional arguments and options/flags using your 40 | info configuration. 41 | 42 | @impl Igniter.Mix.Task 43 | def parse_argv(argv) do 44 | {positional, argv_flags} = positional_args!(argv) 45 | options = options!(argv_flags) 46 | 47 | # custom validation or additional parsing 48 | 49 | %Igniter.Mix.Task.Args{ 50 | argv: argv, 51 | argv_flags: argv_flags, 52 | positional: positional, 53 | options: options 54 | } 55 | end 56 | 57 | ## Options 58 | 59 | The schema is an option parser schema, and `OptionParser` is used to parse the options, with 60 | a few notable differences. 61 | 62 | - The defaults from the `defaults` option in your task info are applied. 63 | - The `:keep` type is automatically aggregated into a list. 64 | - The `:csv` option automatically splits the value on commas, and allows it to be specified multiple times. 65 | This also raises an error if an option with a trailing comma is provided, suggesting that the user remove 66 | the comma or quote the value. 67 | 68 | ## Positional Arguments 69 | 70 | Each positional argument can provide the following options: 71 | 72 | * `:optional` - Whether or not the argument is optional. Defaults to `false`. 73 | * `:rest` - Whether or not the argument consumes the rest of the positional arguments. Defaults to `false`. 74 | The value will be converted to a list automatically. 75 | """ 76 | 77 | @global_options [ 78 | switches: [ 79 | dry_run: :boolean, 80 | yes: :boolean, 81 | yes_to_deps: :boolean, 82 | verbose: :boolean, 83 | only: :keep, 84 | check: :boolean, 85 | scribe: :string, 86 | from_igniter_new: :boolean, 87 | igniter_repeat: :boolean 88 | ], 89 | # no aliases for global options! 90 | aliases: [] 91 | ] 92 | 93 | defstruct schema: [], 94 | defaults: [], 95 | required: [], 96 | aliases: [], 97 | group: nil, 98 | composes: [], 99 | only: nil, 100 | dep_opts: [], 101 | installs: [], 102 | adds_deps: [], 103 | positional: [], 104 | example: nil, 105 | extra_args?: false, 106 | # Used internally 107 | flag_conflicts: %{}, 108 | alias_conflicts: %{} 109 | 110 | @type t :: %__MODULE__{ 111 | schema: Keyword.t(), 112 | defaults: Keyword.t(), 113 | required: [atom()], 114 | aliases: Keyword.t(), 115 | group: atom | nil, 116 | composes: [String.t()], 117 | only: [atom()] | nil, 118 | dep_opts: Keyword.t(), 119 | positional: list(atom | {atom, [{:optional, boolean()}, {:rest, boolean()}]}), 120 | installs: [dep], 121 | adds_deps: [dep], 122 | example: String.t() | nil, 123 | extra_args?: boolean(), 124 | # used internally 125 | flag_conflicts: %{optional(atom) => list(String.t())}, 126 | alias_conflicts: %{optional(atom) => list(String.t())} 127 | } 128 | 129 | @type dep :: 130 | {atom(), String.t()} 131 | | {atom(), Keyword.t()} 132 | | {atom(), String.t(), Keyword.t()} 133 | 134 | def global_options, do: @global_options 135 | end 136 | -------------------------------------------------------------------------------- /lib/igniter/code/map.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Code.Map do 6 | @moduledoc """ 7 | Utilities for working with maps. 8 | """ 9 | 10 | require Igniter.Code.Common 11 | alias Igniter.Code.Common 12 | alias Sourceror.Zipper 13 | 14 | @doc "Puts a value at a path into a map, calling `updater` on the zipper at the value if the key is already present" 15 | @spec put_in_map( 16 | Zipper.t(), 17 | list(term()), 18 | term(), 19 | (Zipper.t() -> {:ok, Zipper.t()} | :error) | nil 20 | ) :: 21 | {:ok, Zipper.t()} | :error 22 | def put_in_map(zipper, path, value, updater \\ nil) do 23 | updater = updater || fn zipper -> {:ok, Common.replace_code(zipper, value)} end 24 | 25 | do_put_in_map(zipper, path, value, updater) 26 | end 27 | 28 | defp do_put_in_map(zipper, [key], value, updater) do 29 | set_map_key(zipper, key, value, updater) 30 | end 31 | 32 | defp do_put_in_map(zipper, [key | rest], value, updater) do 33 | Common.within(zipper, fn zipper -> 34 | cond do 35 | Common.node_matches_pattern?(zipper, {:%{}, _, []}) -> 36 | value = Common.use_aliases(value, zipper) 37 | 38 | {:ok, 39 | Common.replace_code( 40 | zipper, 41 | mappify([key | rest], value) 42 | )} 43 | 44 | Common.node_matches_pattern?(zipper, {:%{}, _, _}) -> 45 | zipper 46 | |> Zipper.down() 47 | |> Common.move_right(fn item -> 48 | if Igniter.Code.Tuple.tuple?(item) do 49 | case Igniter.Code.Tuple.tuple_elem(item, 0) do 50 | {:ok, first_elem} -> 51 | Common.nodes_equal?(first_elem, key) 52 | 53 | :error -> 54 | false 55 | end 56 | end 57 | end) 58 | |> case do 59 | :error -> 60 | value = Common.use_aliases(value, zipper) 61 | format = map_keys_format(zipper) 62 | value = mappify(rest, value) 63 | 64 | {:ok, 65 | Zipper.append_child( 66 | zipper, 67 | {{:__block__, [format: format], [key]}, {:__block__, [], [value]}} 68 | )} 69 | 70 | {:ok, zipper} -> 71 | zipper 72 | |> Igniter.Code.Tuple.tuple_elem(1) 73 | |> case do 74 | {:ok, zipper} -> 75 | do_put_in_map(zipper, rest, value, updater) 76 | 77 | :error -> 78 | :error 79 | end 80 | end 81 | 82 | true -> 83 | :error 84 | end 85 | end) 86 | end 87 | 88 | @doc "Puts a key into a map, calling `updater` on the zipper at the value if the key is already present" 89 | @spec set_map_key(Zipper.t(), term(), term(), (Zipper.t() -> {:ok, Zipper.t()} | :error)) :: 90 | {:ok, Zipper.t()} | :error 91 | def set_map_key(zipper, key, value, updater) do 92 | cond do 93 | Common.node_matches_pattern?(zipper, {:%{}, _, []}) -> 94 | value = Common.use_aliases(value, zipper) 95 | 96 | {:ok, 97 | Common.replace_code( 98 | zipper, 99 | mappify([key], value) 100 | )} 101 | 102 | Common.node_matches_pattern?(zipper, {:%{}, _, _}) -> 103 | Common.within(zipper, fn zipper -> 104 | zipper 105 | |> Zipper.down() 106 | |> Common.move_right(fn item -> 107 | if Igniter.Code.Tuple.tuple?(item) do 108 | case Igniter.Code.Tuple.tuple_elem(item, 0) do 109 | {:ok, first_elem} -> 110 | Common.nodes_equal?(first_elem, key) 111 | 112 | :error -> 113 | false 114 | end 115 | end 116 | end) 117 | |> case do 118 | :error -> 119 | value = Common.use_aliases(value, zipper) 120 | 121 | format = map_keys_format(zipper) 122 | 123 | {:ok, 124 | Zipper.append_child( 125 | zipper, 126 | {{:__block__, [format: format], [key]}, {:__block__, [], [value]}} 127 | )} 128 | 129 | {:ok, zipper} -> 130 | zipper 131 | |> Igniter.Code.Tuple.tuple_elem(1) 132 | |> case do 133 | {:ok, zipper} -> 134 | {:ok, updater.(zipper)} 135 | 136 | :error -> 137 | :error 138 | end 139 | end 140 | end) 141 | 142 | true -> 143 | :error 144 | end 145 | end 146 | 147 | defp map_keys_format(zipper) do 148 | case Zipper.children(zipper.node) do 149 | value when is_list(value) -> 150 | Enum.all?(value, fn 151 | {{:__block__, meta, _}, _} -> 152 | meta[:format] == :keyword 153 | 154 | {:__block__, meta, _} -> 155 | meta[:format] == :keyword 156 | 157 | _ -> 158 | false 159 | end) 160 | |> case do 161 | true -> 162 | :keyword 163 | 164 | false -> 165 | :map 166 | end 167 | 168 | _ -> 169 | :map 170 | end 171 | end 172 | 173 | @doc "Puts a value into nested maps at the given path" 174 | def mappify([], value) do 175 | value 176 | end 177 | 178 | def mappify([key | rest], value) do 179 | format = 180 | if is_atom(key) do 181 | :keyword 182 | else 183 | :map 184 | end 185 | 186 | {:%{}, [], [{{:__block__, [format: format], [key]}, mappify(rest, value)}]} 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /lib/igniter/util/loading.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Util.Loading do 6 | @moduledoc """ 7 | Utilities for doing operations with loading spinners. 8 | """ 9 | 10 | @spinners "⠁⠂⠄⡀⡁⡂⡄⡅⡇⡏⡗⡧⣇⣏⣗⣧⣯⣷⣿⢿⣻⢻⢽⣹⢹⢸⠸⢘⠘⠨⢈⠈⠐⠠⢀" |> String.graphemes() 11 | 12 | @doc """ 13 | Runs the function with a loading spinner, suppressing all output. 14 | """ 15 | def with_spinner(name, fun, opts \\ []) do 16 | if opts[:verbose?] do 17 | Mix.shell().info(name <> ":") 18 | result = fun.() 19 | Mix.shell().info(name <> ": " <> "#{IO.ANSI.green()}✔#{IO.ANSI.reset()}") 20 | result 21 | else 22 | shell = Mix.shell() 23 | 24 | # I don't understand why I have to do this 25 | if !Code.ensure_loaded?(Mix.Shell.ActuallyQuiet) do 26 | Code.eval_quoted( 27 | quote do 28 | defmodule Mix.Shell.ActuallyQuiet do 29 | @moduledoc false 30 | @behaviour Mix.Shell 31 | 32 | def print_app, do: :ok 33 | def info(_message), do: :ok 34 | 35 | def error(message) do 36 | # Instead of discarding, send to stderr so it gets captured 37 | IO.puts(:stderr, message) 38 | :ok 39 | end 40 | 41 | def prompt(_message), do: :ok 42 | def yes?(_message, _options \\ []), do: :ok 43 | 44 | def cmd(command, opts \\ []) do 45 | Mix.Shell.cmd(command, opts, fn data -> data end) 46 | end 47 | end 48 | end 49 | ) 50 | end 51 | 52 | {:ok, _} = Igniter.CaptureServer.start_link([]) 53 | 54 | loader = 55 | if shell == Mix.Shell.IO do 56 | spawn_loader(name) 57 | end 58 | 59 | try do 60 | Mix.shell(Mix.Shell.ActuallyQuiet) 61 | 62 | {:ok, ref} = 63 | Igniter.CaptureServer.device_capture_on(:standard_error, :unicode, "") 64 | 65 | try do 66 | with_log(fn -> 67 | do_with_io(self(), [], fn -> 68 | fun.() 69 | end) 70 | |> elem(0) 71 | end) 72 | |> elem(0) 73 | catch 74 | kind, reason -> 75 | Process.put(:spinner_error, true) 76 | Mix.shell(shell) 77 | Mix.shell().info(Igniter.CaptureServer.device_output(:standard_error, ref)) 78 | :erlang.raise(kind, reason, __STACKTRACE__) 79 | after 80 | Mix.shell(shell) 81 | Igniter.CaptureServer.device_capture_off(ref) 82 | GenServer.stop(Igniter.CaptureServer) 83 | end 84 | after 85 | if shell == Mix.Shell.IO do 86 | loader_ref = make_ref() 87 | error_occurred = Process.get(:spinner_error, false) 88 | 89 | stop_message = 90 | if error_occurred, 91 | do: {:stop_error, self(), loader_ref}, 92 | else: {:stop_success, self(), loader_ref} 93 | 94 | send(loader, stop_message) 95 | 96 | receive do 97 | {:loader_stopped, ^loader_ref} -> 98 | nil 99 | after 100 | 500 -> 101 | :ok 102 | end 103 | end 104 | end 105 | end 106 | end 107 | 108 | defp do_with_io(pid, options, fun) when is_pid(pid) do 109 | prompt_config = Keyword.get(options, :capture_prompt, true) 110 | encoding = Keyword.get(options, :encoding, :unicode) 111 | input = Keyword.get(options, :input, "") 112 | 113 | {:group_leader, original_gl} = 114 | Process.info(pid, :group_leader) || {:group_leader, Process.group_leader()} 115 | 116 | {:ok, capture_gl} = StringIO.open(input, capture_prompt: prompt_config, encoding: encoding) 117 | 118 | try do 119 | Process.group_leader(pid, capture_gl) 120 | do_capture_gl(capture_gl, fun) 121 | after 122 | Process.group_leader(pid, original_gl) 123 | end 124 | end 125 | 126 | defp do_capture_gl(string_io, fun) do 127 | fun.() 128 | catch 129 | kind, reason -> 130 | _ = StringIO.close(string_io) 131 | :erlang.raise(kind, reason, __STACKTRACE__) 132 | else 133 | result -> 134 | {:ok, {_input, output}} = StringIO.close(string_io) 135 | {result, output} 136 | end 137 | 138 | def spawn_loader(task_name) do 139 | spawn_link(fn -> 140 | @spinners 141 | |> Stream.cycle() 142 | |> Stream.map(fn next -> 143 | receive do 144 | {:stop_success, pid, ref} -> 145 | IO.puts("\r\e[K" <> task_name <> " " <> "#{IO.ANSI.green()}✔#{IO.ANSI.reset()}") 146 | send(pid, {:loader_stopped, ref}) 147 | exit(:normal) 148 | 149 | {:stop_error, pid, ref} -> 150 | IO.puts("\r\e[K" <> task_name <> " " <> "#{IO.ANSI.red()}✗#{IO.ANSI.reset()}") 151 | send(pid, {:loader_stopped, ref}) 152 | exit(:normal) 153 | after 154 | 0 -> 155 | IO.write("\r\e[K" <> task_name <> " " <> next) 156 | :timer.sleep(50) 157 | end 158 | end) 159 | |> Stream.run() 160 | end) 161 | end 162 | 163 | def with_log(opts \\ [], fun) when is_list(opts) do 164 | opts = 165 | if opts[:level] == :warn do 166 | IO.warn("level: :warn is deprecated, please use :warning instead") 167 | Keyword.put(opts, :level, :warning) 168 | else 169 | opts 170 | end 171 | 172 | {:ok, string_io} = StringIO.open("") 173 | 174 | try do 175 | ref = Igniter.CaptureServer.log_capture_on(self(), string_io, opts) 176 | 177 | try do 178 | fun.() 179 | after 180 | :ok = Logger.flush() 181 | :ok = Igniter.CaptureServer.log_capture_off(ref) 182 | end 183 | catch 184 | kind, reason -> 185 | _ = StringIO.close(string_io) 186 | :erlang.raise(kind, reason, __STACKTRACE__) 187 | else 188 | result -> 189 | {:ok, {_input, output}} = StringIO.close(string_io) 190 | {result, output} 191 | end 192 | end 193 | end 194 | -------------------------------------------------------------------------------- /installer/lib/loading.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Installer.Loading do 6 | @moduledoc """ 7 | Utilities for doing operations with loading spinners. 8 | """ 9 | 10 | @spinners "⠁⠂⠄⡀⡁⡂⡄⡅⡇⡏⡗⡧⣇⣏⣗⣧⣯⣷⣿⢿⣻⢻⢽⣹⢹⢸⠸⢘⠘⠨⢈⠈⠐⠠⢀" |> String.graphemes() 11 | 12 | @doc """ 13 | Runs the function with a loading spinner, suppressing all output. 14 | """ 15 | def with_spinner(name, fun, opts \\ []) do 16 | if opts[:verbose?] do 17 | Mix.shell().info(name <> ":") 18 | result = fun.() 19 | Mix.shell().info(name <> ": " <> "#{IO.ANSI.green()}✔#{IO.ANSI.reset()}") 20 | result 21 | else 22 | shell = Mix.shell() 23 | 24 | # I don't understand why I have to do this 25 | if !Code.ensure_loaded?(Mix.Shell.ActuallyQuiet) do 26 | Code.eval_quoted( 27 | quote do 28 | defmodule Mix.Shell.ActuallyQuiet do 29 | @moduledoc false 30 | @behaviour Mix.Shell 31 | 32 | def print_app, do: :ok 33 | 34 | def info(_message), do: :ok 35 | 36 | def error(message) do 37 | # Instead of discarding, send to stderr so it gets captured 38 | IO.puts(:stderr, message) 39 | :ok 40 | end 41 | 42 | def prompt(_message), do: :ok 43 | 44 | def yes?(_message, _options \\ []), do: :ok 45 | 46 | def cmd(command, opts \\ []) do 47 | Mix.Shell.cmd(command, opts, fn data -> data end) 48 | end 49 | end 50 | end 51 | ) 52 | end 53 | 54 | {:ok, _} = Igniter.Installer.CaptureServer.start_link([]) 55 | 56 | loader = 57 | if shell == Mix.Shell.IO do 58 | spawn_loader(name) 59 | end 60 | 61 | try do 62 | Mix.shell(Mix.Shell.ActuallyQuiet) 63 | 64 | {:ok, ref} = 65 | Igniter.Installer.CaptureServer.device_capture_on(:standard_error, :unicode, "") 66 | 67 | try do 68 | with_log(fn -> 69 | do_with_io(self(), [], fn -> 70 | fun.() 71 | end) 72 | |> elem(0) 73 | end) 74 | |> elem(0) 75 | catch 76 | kind, reason -> 77 | Process.put(:spinner_error, true) 78 | Mix.shell(shell) 79 | Mix.shell().info(Igniter.Installer.CaptureServer.device_output(:standard_error, ref)) 80 | :erlang.raise(kind, reason, __STACKTRACE__) 81 | after 82 | Mix.shell(shell) 83 | Igniter.Installer.CaptureServer.device_capture_off(ref) 84 | GenServer.stop(Igniter.Installer.CaptureServer) 85 | end 86 | after 87 | if shell == Mix.Shell.IO do 88 | loader_ref = make_ref() 89 | error_occurred = Process.get(:spinner_error, false) 90 | 91 | stop_message = 92 | if error_occurred, 93 | do: {:stop_error, self(), loader_ref}, 94 | else: {:stop_success, self(), loader_ref} 95 | 96 | send(loader, stop_message) 97 | 98 | receive do 99 | {:loader_stopped, ^loader_ref} -> 100 | nil 101 | after 102 | 500 -> 103 | :ok 104 | end 105 | end 106 | end 107 | end 108 | end 109 | 110 | defp do_with_io(pid, options, fun) when is_pid(pid) do 111 | prompt_config = Keyword.get(options, :capture_prompt, true) 112 | encoding = Keyword.get(options, :encoding, :unicode) 113 | input = Keyword.get(options, :input, "") 114 | 115 | {:group_leader, original_gl} = 116 | Process.info(pid, :group_leader) || {:group_leader, Process.group_leader()} 117 | 118 | {:ok, capture_gl} = StringIO.open(input, capture_prompt: prompt_config, encoding: encoding) 119 | 120 | try do 121 | Process.group_leader(pid, capture_gl) 122 | do_capture_gl(capture_gl, fun) 123 | after 124 | Process.group_leader(pid, original_gl) 125 | end 126 | end 127 | 128 | defp do_capture_gl(string_io, fun) do 129 | try do 130 | fun.() 131 | catch 132 | kind, reason -> 133 | _ = StringIO.close(string_io) 134 | :erlang.raise(kind, reason, __STACKTRACE__) 135 | else 136 | result -> 137 | {:ok, {_input, output}} = StringIO.close(string_io) 138 | {result, output} 139 | end 140 | end 141 | 142 | def spawn_loader(task_name) do 143 | spawn_link(fn -> 144 | @spinners 145 | |> Stream.cycle() 146 | |> Stream.map(fn next -> 147 | receive do 148 | {:stop_success, pid, ref} -> 149 | IO.puts("\r\e[K" <> task_name <> " " <> "#{IO.ANSI.green()}✔#{IO.ANSI.reset()}") 150 | send(pid, {:loader_stopped, ref}) 151 | exit(:normal) 152 | 153 | {:stop_error, pid, ref} -> 154 | IO.puts("\r\e[K" <> task_name <> " " <> "#{IO.ANSI.red()}✗#{IO.ANSI.reset()}") 155 | send(pid, {:loader_stopped, ref}) 156 | exit(:normal) 157 | after 158 | 0 -> 159 | IO.write("\r\e[K" <> task_name <> " " <> next) 160 | :timer.sleep(50) 161 | end 162 | end) 163 | |> Stream.run() 164 | end) 165 | end 166 | 167 | def with_log(opts \\ [], fun) when is_list(opts) do 168 | opts = 169 | if opts[:level] == :warn do 170 | IO.warn("level: :warn is deprecated, please use :warning instead") 171 | Keyword.put(opts, :level, :warning) 172 | else 173 | opts 174 | end 175 | 176 | {:ok, string_io} = StringIO.open("") 177 | 178 | try do 179 | ref = Igniter.Installer.CaptureServer.log_capture_on(self(), string_io, opts) 180 | 181 | try do 182 | fun.() 183 | after 184 | :ok = Logger.flush() 185 | :ok = Igniter.Installer.CaptureServer.log_capture_off(ref) 186 | end 187 | catch 188 | kind, reason -> 189 | _ = StringIO.close(string_io) 190 | :erlang.raise(kind, reason, __STACKTRACE__) 191 | else 192 | result -> 193 | {:ok, {_input, output}} = StringIO.close(string_io) 194 | {result, output} 195 | end 196 | end 197 | end 198 | -------------------------------------------------------------------------------- /lib/igniter/extensions/phoenix.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Extensions.Phoenix do 6 | @moduledoc """ 7 | A phoenix extension for Igniter. 8 | 9 | Install with `mix igniter.add_extension phoenix` 10 | """ 11 | use Igniter.Extension 12 | 13 | def proper_location(igniter, module, opts) do 14 | case Keyword.get(opts, :location_convention, :phoenix_generators) do 15 | :phoenix_generators -> 16 | phoenix_generators_proper_location(igniter, module) 17 | 18 | other -> 19 | raise "Unknown phoenix location convention #{inspect(other)}" 20 | end 21 | end 22 | 23 | defp phoenix_generators_proper_location(igniter, module) do 24 | split = Module.split(module) 25 | 26 | cond do 27 | # Check if this is a LiveView/LiveComponent module but NOT if "Live" is just a namespace 28 | # Exclude the case where element 1 is exactly "Live" (namespace usage) 29 | String.ends_with?(Enum.at(split, 1) || "", "Live") && 30 | Enum.at(split, 1) != "Live" -> 31 | [base | rest] = split 32 | 33 | {:ok, 34 | base 35 | |> Macro.underscore() 36 | |> Path.join("live") 37 | |> then(fn path -> 38 | rest 39 | |> Enum.map(&Macro.underscore/1) 40 | |> case do 41 | [] -> [path] 42 | nested -> [path | nested] 43 | end 44 | |> Path.join() 45 | end)} 46 | 47 | String.ends_with?(to_string(module), "Web.Layouts") && Enum.count(split) == 2 -> 48 | [base | rest] = split 49 | 50 | [type] = List.last(split) |> String.split("Controller", trim: true) 51 | 52 | rest = :lists.droplast(rest) 53 | 54 | {:ok, 55 | base 56 | |> Macro.underscore() 57 | |> Path.join("components") 58 | |> then(fn path -> 59 | rest 60 | |> Enum.map(&Macro.underscore/1) 61 | |> case do 62 | [] -> [path] 63 | nested -> [path | nested] 64 | end 65 | |> Path.join() 66 | end) 67 | |> Path.join(Macro.underscore(type) <> ".ex")} 68 | 69 | String.ends_with?(to_string(module), "Controller") && List.last(split) != "Controller" && 70 | String.ends_with?(List.first(split), "Web") -> 71 | [base | rest] = split 72 | 73 | [type] = List.last(split) |> String.split("Controller", trim: true) 74 | 75 | rest = :lists.droplast(rest) 76 | 77 | {:ok, 78 | base 79 | |> Macro.underscore() 80 | |> Path.join("controllers") 81 | |> then(fn path -> 82 | rest 83 | |> Enum.map(&Macro.underscore/1) 84 | |> case do 85 | [] -> [path] 86 | nested -> [path | nested] 87 | end 88 | |> Path.join() 89 | end) 90 | |> Path.join(Macro.underscore(type) <> "_controller.ex")} 91 | 92 | String.ends_with?(to_string(module), "HTML") && List.last(split) != "HTML" && 93 | String.ends_with?(List.first(split), "Web") -> 94 | [base | rest] = split 95 | 96 | [type] = List.last(split) |> String.split("HTML", trim: true) 97 | 98 | rest = :lists.droplast(rest) 99 | 100 | potential_controller_module = 101 | Module.concat([base | rest] ++ [type <> "Controller"]) 102 | 103 | {exists?, _} = Igniter.Project.Module.module_exists(igniter, potential_controller_module) 104 | 105 | if List.last(split) == "ErrorHTML" || 106 | (exists? && Igniter.Libs.Phoenix.controller?(igniter, potential_controller_module)) do 107 | {:ok, 108 | base 109 | |> Macro.underscore() 110 | |> Path.join("controllers") 111 | |> then(fn path -> 112 | rest 113 | |> Enum.map(&Macro.underscore/1) 114 | |> case do 115 | [] -> [path] 116 | nested -> [path | nested] 117 | end 118 | |> Path.join() 119 | end) 120 | |> Path.join(Macro.underscore(type) <> "_html.ex")} 121 | else 122 | :error 123 | end 124 | 125 | String.ends_with?(to_string(module), "CoreComponents") && 126 | String.contains?(to_string(module), "Web") -> 127 | [base | rest] = split 128 | 129 | [type] = List.last(split) |> String.split("HTML", trim: true) 130 | 131 | rest = :lists.droplast(rest) 132 | 133 | {:ok, 134 | base 135 | |> Macro.underscore() 136 | |> Path.join("components") 137 | |> then(fn path -> 138 | rest 139 | |> Enum.map(&Macro.underscore/1) 140 | |> case do 141 | [] -> [path] 142 | nested -> [path | nested] 143 | end 144 | |> Path.join() 145 | end) 146 | |> Path.join(Macro.underscore(type) <> ".ex")} 147 | 148 | String.ends_with?(to_string(module), "JSON") && List.last(Module.split(module)) != "JSON" && 149 | String.ends_with?(List.first(Module.split(module)), "Web") -> 150 | [base | rest] = split 151 | 152 | [type] = List.last(split) |> String.split("JSON", trim: true) 153 | 154 | rest = :lists.droplast(rest) 155 | 156 | potential_controller_module = 157 | Module.concat([base | rest] ++ [type <> "Controller"]) 158 | 159 | {exists?, _} = Igniter.Project.Module.module_exists(igniter, potential_controller_module) 160 | 161 | if List.last(split) == "ErrorJSON" || 162 | (exists? && Igniter.Libs.Phoenix.controller?(igniter, potential_controller_module)) do 163 | {:ok, 164 | base 165 | |> Macro.underscore() 166 | |> Path.join("controllers") 167 | |> then(fn path -> 168 | rest 169 | |> Enum.map(&Macro.underscore/1) 170 | |> case do 171 | [] -> [path] 172 | nested -> [path | nested] 173 | end 174 | |> Path.join() 175 | end) 176 | |> Path.join(Macro.underscore(type) <> "_json.ex")} 177 | else 178 | :error 179 | end 180 | 181 | true -> 182 | :error 183 | end 184 | end 185 | end 186 | -------------------------------------------------------------------------------- /lib/igniter/project/formatter.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Igniter.Project.Formatter do 6 | @moduledoc "Codemods and utilities for interacting with `.formatter.exs` files" 7 | alias Igniter.Code.Common 8 | alias Sourceror.Zipper 9 | 10 | @default_formatter """ 11 | # Used by "mix format" 12 | [ 13 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 14 | ] 15 | """ 16 | 17 | @doc """ 18 | Adds a new dep to the list of imported deps in the root `.formatter.exs` 19 | """ 20 | @spec import_dep(Igniter.t(), dep :: atom) :: Igniter.t() 21 | def import_dep(igniter, dep) do 22 | igniter 23 | |> Igniter.include_or_create_file(".formatter.exs", @default_formatter) 24 | |> Igniter.update_elixir_file(".formatter.exs", fn zipper -> 25 | zipper 26 | |> Zipper.down() 27 | |> case do 28 | nil -> 29 | code = 30 | quote do 31 | [import_deps: [unquote(dep)]] 32 | end 33 | 34 | {:ok, Common.add_code(zipper, code)} 35 | 36 | zipper -> 37 | zipper 38 | |> Zipper.rightmost() 39 | |> Igniter.Code.Keyword.put_in_keyword([:import_deps], [dep], fn nested_zipper -> 40 | Igniter.Code.List.prepend_new_to_list( 41 | nested_zipper, 42 | dep 43 | ) 44 | end) 45 | |> case do 46 | {:ok, zipper} -> 47 | zipper 48 | 49 | :error -> 50 | {:warning, 51 | """ 52 | Could not import dependency #{inspect(dep)} into `.formatter.exs`. 53 | 54 | Please add the import manually, i.e 55 | 56 | import_deps: [#{inspect(dep)}] 57 | """} 58 | end 59 | end 60 | end) 61 | end 62 | 63 | @doc """ 64 | Removes an imported dep from the list of imported deps in the root `.formatter.exs` 65 | """ 66 | @spec remove_imported_dep(Igniter.t(), dep :: atom) :: Igniter.t() 67 | def remove_imported_dep(igniter, dep) do 68 | igniter 69 | |> Igniter.include_or_create_file(".formatter.exs", @default_formatter) 70 | |> Igniter.update_elixir_file(".formatter.exs", fn zipper -> 71 | zipper 72 | |> Zipper.down() 73 | |> case do 74 | nil -> 75 | {:ok, zipper} 76 | 77 | zipper -> 78 | zipper 79 | |> Zipper.rightmost() 80 | |> Igniter.Code.Keyword.put_in_keyword([:import_deps], [], fn nested_zipper -> 81 | Igniter.Code.List.remove_from_list( 82 | nested_zipper, 83 | &Igniter.Code.Common.nodes_equal?(&1, dep) 84 | ) 85 | end) 86 | |> case do 87 | {:ok, zipper} -> 88 | zipper 89 | 90 | :error -> 91 | {:warning, 92 | """ 93 | Could not remove imported dependency #{inspect(dep)} from `.formatter.exs`. 94 | 95 | Please remove the import manually, i.e replacing 96 | 97 | 98 | import_deps: [:foo, #{inspect(dep)}, :bar] 99 | 100 | with 101 | 102 | import_deps: [:foo, :bar] 103 | """} 104 | end 105 | end 106 | end) 107 | end 108 | 109 | @doc """ 110 | Adds a new plugin to the list of plugins in the root `.formatter.exs` 111 | """ 112 | @spec add_formatter_plugin(Igniter.t(), plugin :: module()) :: Igniter.t() 113 | def add_formatter_plugin(igniter, plugin) do 114 | igniter 115 | |> Igniter.include_or_create_file(".formatter.exs", @default_formatter) 116 | |> Igniter.update_elixir_file(".formatter.exs", fn zipper -> 117 | zipper 118 | |> Zipper.down() 119 | |> case do 120 | nil -> 121 | code = 122 | quote do 123 | [plugins: [unquote(plugin)]] 124 | end 125 | 126 | {:ok, Common.add_code(zipper, code)} 127 | 128 | zipper -> 129 | zipper 130 | |> Zipper.rightmost() 131 | |> Igniter.Code.Keyword.put_in_keyword( 132 | [:plugins], 133 | [plugin], 134 | fn nested_zipper -> 135 | Igniter.Code.List.prepend_new_to_list( 136 | nested_zipper, 137 | plugin 138 | ) 139 | end 140 | ) 141 | |> case do 142 | {:ok, zipper} -> 143 | {:ok, zipper} 144 | 145 | _ -> 146 | {:warning, 147 | """ 148 | Could not add formatter plugin #{inspect(plugin)} into `.formatter.exs`. 149 | 150 | Please add the plugin manually, i.e 151 | 152 | plugins: [#{inspect(plugin)}] 153 | """} 154 | end 155 | end 156 | end) 157 | end 158 | 159 | @doc """ 160 | REmoves a plugin to the list of plugins in the root `.formatter.exs` 161 | """ 162 | @spec remove_formatter_plugin(Igniter.t(), plugin :: module()) :: Igniter.t() 163 | def remove_formatter_plugin(igniter, plugin) do 164 | igniter 165 | |> Igniter.include_or_create_file(".formatter.exs", @default_formatter) 166 | |> Igniter.update_elixir_file(".formatter.exs", fn zipper -> 167 | zipper 168 | |> Zipper.down() 169 | |> case do 170 | nil -> 171 | {:ok, zipper} 172 | 173 | zipper -> 174 | zipper 175 | |> Zipper.rightmost() 176 | |> Igniter.Code.Keyword.put_in_keyword( 177 | [:plugins], 178 | [], 179 | fn nested_zipper -> 180 | Igniter.Code.List.remove_from_list( 181 | nested_zipper, 182 | &Igniter.Code.Common.nodes_equal?(&1, plugin) 183 | ) 184 | end 185 | ) 186 | |> case do 187 | {:ok, zipper} -> 188 | zipper 189 | 190 | _ -> 191 | {:warning, 192 | """ 193 | Could not remove formatter plugin #{inspect(plugin)} from `.formatter.exs`. 194 | 195 | Please remove the plugin manually, i.e by replacing 196 | 197 | plugins: [Foo, #{inspect(plugin)}, Bar] 198 | 199 | with 200 | 201 | plugins: [Foo, Bar] 202 | """} 203 | end 204 | end 205 | end) 206 | end 207 | end 208 | -------------------------------------------------------------------------------- /lib/mix/tasks/igniter.phx.install.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Igniter.Phx.Install do 6 | @moduledoc false 7 | use Igniter.Mix.Task 8 | 9 | @example "mix igniter.phx.install . --module MyApp --app my_app" 10 | # No docs yet, its only for testing 11 | # @shortdoc "Creates a new Phoenix project in the current application." 12 | 13 | # @moduledoc """ 14 | # #{@shortdoc} 15 | # 16 | # ## Example 17 | # 18 | # ```bash 19 | # #{@example} 20 | # ``` 21 | # 22 | # ## Options 23 | # 24 | # * `--app` - the name of the OTP application 25 | # 26 | # * `--module` - the name of the base module in 27 | # the generated skeleton 28 | # 29 | # * `--database` - specify the database adapter for Ecto. One of: 30 | # 31 | # * `postgres` - via https://github.com/elixir-ecto/postgrex 32 | # * `mysql` - via https://github.com/elixir-ecto/myxql 33 | # * `mssql` - via https://github.com/livehelpnow/tds 34 | # * `sqlite3` - via https://github.com/elixir-sqlite/ecto_sqlite3 35 | # 36 | # Please check the driver docs for more information 37 | # and requirements. Defaults to "postgres". 38 | # 39 | # * `--adapter` - specify the http adapter. One of: 40 | # * `cowboy` - via https://github.com/elixir-plug/plug_cowboy 41 | # * `bandit` - via https://github.com/mtrudel/bandit 42 | # 43 | # Please check the adapter docs for more information 44 | # and requirements. Defaults to "bandit". 45 | # 46 | # * `--no-assets` - equivalent to `--no-esbuild` and `--no-tailwind` 47 | # 48 | # * `--no-dashboard` - do not include Phoenix.LiveDashboard 49 | # 50 | # * `--no-ecto` - do not generate Ecto files 51 | # 52 | # * `--no-esbuild` - do not include esbuild dependencies and assets. 53 | # We do not recommend setting this option, unless for API only 54 | # applications, as doing so requires you to manually add and 55 | # track JavaScript dependencies 56 | # 57 | # * `--no-gettext` - do not generate gettext files 58 | # 59 | # * `--no-html` - do not generate HTML views 60 | # 61 | # * `--no-live` - comment out LiveView socket setup in your Endpoint 62 | # and assets/js/app.js. Automatically disabled if --no-html is given 63 | # 64 | # * `--no-mailer` - do not generate Swoosh mailer files 65 | # 66 | # * `--no-tailwind` - do not include tailwind dependencies and assets. 67 | # The generated markup will still include Tailwind CSS classes, those 68 | # are left-in as reference for the subsequent styling of your layout 69 | # and components 70 | # 71 | # * `--binary-id` - use `binary_id` as primary key type in Ecto schemas 72 | # 73 | # * `--verbose` - use verbose output 74 | # 75 | # When passing the `--no-ecto` flag, Phoenix generators such as 76 | # `phx.gen.html`, `phx.gen.json`, `phx.gen.live`, and `phx.gen.context` 77 | # may no longer work as expected as they generate context files that rely 78 | # on Ecto for the database access. In those cases, you can pass the 79 | # `--no-context` flag to generate most of the HTML and JSON files 80 | # but skip the context, allowing you to fill in the blanks as desired. 81 | # 82 | # Similarly, if `--no-html` is given, the files generated by 83 | # `phx.gen.html` will no longer work, as important HTML components 84 | # will be missing. 85 | # 86 | # """ 87 | 88 | def info(_argv, _source) do 89 | %Igniter.Mix.Task.Info{ 90 | group: :igniter, 91 | example: @example, 92 | positional: [:base_path], 93 | schema: [ 94 | app: :string, 95 | module: :string, 96 | database: :string, 97 | adapter: :string, 98 | assets: :boolean, 99 | dashboard: :boolean, 100 | ecto: :boolean, 101 | esbuild: :boolean, 102 | gettext: :boolean, 103 | html: :boolean, 104 | live: :boolean, 105 | mailer: :boolean, 106 | tailwind: :boolean, 107 | binary_id: :boolean, 108 | verbose: :boolean 109 | ] 110 | } 111 | end 112 | 113 | def igniter(igniter) do 114 | elixir_version_check!() 115 | 116 | if !Code.ensure_loaded?(Phx.New.Generator) do 117 | Mix.raise(""" 118 | Phoenix installer is not available. Please install it before proceeding: 119 | 120 | mix archive.install hex phx_new 121 | 122 | """) 123 | end 124 | 125 | if igniter.args.options[:umbrella] do 126 | Mix.raise("Umbrella projects are not supported yet.") 127 | end 128 | 129 | %{base_path: base_path} = igniter.args.positional 130 | 131 | generate(igniter, base_path, {Phx.New.Single, Igniter.Phoenix.Single}, igniter.args.options) 132 | end 133 | 134 | defp generate(igniter, base_path, {phx_generator, igniter_generator}, opts) do 135 | project = 136 | apply(Phx.New.Project, :new, [base_path, opts]) 137 | |> phx_generator.prepare_project() 138 | 139 | project = 140 | apply(Phx.New.Generator, :put_binding, [project]) 141 | |> validate_project() 142 | 143 | igniter 144 | |> Igniter.compose_task("igniter.add_extension", ["phoenix"]) 145 | |> igniter_generator.generate(project) 146 | end 147 | 148 | defp validate_project(%{opts: opts} = project) do 149 | check_app_name!(project.app, !!opts[:app]) 150 | check_module_name_validity!(project.root_mod) 151 | 152 | project 153 | end 154 | 155 | defp check_app_name!(name, from_app_flag) do 156 | unless name =~ Regex.recompile!(~r/^[a-z][a-z0-9_]*$/) do 157 | extra = 158 | if from_app_flag do 159 | "" 160 | else 161 | ". The application name is inferred from the path, if you'd like to " <> 162 | "explicitly name the application then use the `--app APP` option." 163 | end 164 | 165 | Mix.raise( 166 | "Application name must start with a letter and have only lowercase " <> 167 | "letters, numbers and underscore, got: #{inspect(name)}" <> extra 168 | ) 169 | end 170 | end 171 | 172 | defp check_module_name_validity!(name) do 173 | unless inspect(name) =~ Regex.recompile!(~r/^[A-Z]\w*(\.[A-Z]\w*)*$/) do 174 | Mix.raise( 175 | "Module name must be a valid Elixir alias (for example: Foo.Bar), got: #{inspect(name)}" 176 | ) 177 | end 178 | end 179 | 180 | defp elixir_version_check! do 181 | unless Version.match?(System.version(), "~> 1.15") do 182 | Mix.raise( 183 | "mix igniter.phx.install requires at least Elixir v1.15\n " <> 184 | "You have #{System.version()}. Please update accordingly." 185 | ) 186 | end 187 | end 188 | end 189 | -------------------------------------------------------------------------------- /documentation/documenting-tasks.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | # Documenting Tasks 9 | 10 | Igniter.Scribe is a powerful tool that allows you to automatically generate documentation for your installers and mix tasks. Instead of writing static documentation that can quickly become outdated, you can create living documentation that shows exactly what your tasks do by running them in a test environment. 11 | 12 | ## Overview 13 | 14 | The `--scribe` option available on all Igniter mix tasks enables automatic documentation generation. When you run a task with this option, Igniter will: 15 | 16 | 1. Set up a test project environment 17 | 2. Execute your task's logic 18 | 3. Capture all the changes made to files 19 | 4. Generate a markdown document showing the step-by-step process 20 | 5. Save the documentation to the specified file path 21 | 22 | ## Basic Usage 23 | 24 | To generate documentation for any Igniter mix task, use the `--scribe` option followed by the output file path: 25 | 26 | ```bash 27 | mix your.task --scribe documentation/tutorials/your-guide.md 28 | ``` 29 | 30 | ## Setting Up Your Task for Scribe 31 | 32 | ```elixir 33 | defmodule Mix.Tasks.YourLibrary.Install do 34 | @shortdoc "Installs YourLibrary into a project" 35 | 36 | @moduledoc """ 37 | #{@shortdoc} 38 | 39 | ## Options 40 | 41 | - `--example` - Creates example resources 42 | """ 43 | 44 | use Igniter.Mix.Task 45 | 46 | @impl Igniter.Mix.Task 47 | def igniter(igniter) do 48 | igniter 49 | |> Igniter.Scribe.start_document( 50 | "Manual Installation Guide", 51 | @manual_lead_in, 52 | app_name: :my_app 53 | ) 54 | |> add_your_sections() 55 | end 56 | end 57 | ``` 58 | 59 | ## Scribe API Reference 60 | 61 | ### `start_document/4` 62 | 63 | Initializes the documentation with a title and introduction. Only the first call to this function is honored. 64 | 65 | ```elixir 66 | Igniter.Scribe.start_document(igniter, title, contents, opts \\ []) 67 | ``` 68 | 69 | **Parameters:** 70 | - `igniter` - The Igniter struct 71 | - `title` - The main title for the document (will be rendered as `# Title`) 72 | - `contents` - Introduction text that appears after the title 73 | - `opts` - Optional keyword list (can include `:app_name` and other options) 74 | 75 | **Example:** 76 | 77 | ```elixir 78 | @manual_lead_in """ 79 | This guide will walk you through the process of manually installing YourLibrary into your project. 80 | If you are starting from scratch, you can use `mix new` or `mix igniter.new` and follow these instructions. 81 | """ 82 | 83 | igniter 84 | |> Igniter.Scribe.start_document( 85 | "Manual Installation", 86 | @manual_lead_in, 87 | app_name: :my_app 88 | ) 89 | ``` 90 | 91 | ### `section/3` 92 | 93 | Creates a new section in the documentation with a header, explanation, and the actual changes. 94 | 95 | ```elixir 96 | Igniter.Scribe.section(igniter, header, explanation, callback) 97 | ``` 98 | 99 | **Parameters:** 100 | - `igniter` - The Igniter struct 101 | - `header` - The section header text 102 | - `explanation` - Descriptive text explaining what this section does 103 | - `callback` - A function that receives the igniter and performs the actual changes 104 | 105 | **Example:** 106 | 107 | ```elixir 108 | @setup_dependencies """ 109 | Install and configure the required dependencies for YourLibrary. 110 | This will add the necessary packages to your mix.exs file. 111 | """ 112 | 113 | igniter 114 | |> Igniter.Scribe.section("Setup Dependencies", @setup_dependencies, fn igniter -> 115 | igniter 116 | |> Igniter.Scribe.patch(&Igniter.Project.Deps.add_dep(&1, {:other_library, "~> 1.0"})) 117 | |> Igniter.Scribe.patch(&Igniter.compose_task(&1, "other_library.install")) 118 | end) 119 | ``` 120 | 121 | ### `patch/2` 122 | 123 | Captures changes made by a function and includes them in the documentation as code diffs. 124 | 125 | ```elixir 126 | Igniter.Scribe.patch(igniter, callback) 127 | ``` 128 | 129 | **Parameters:** 130 | - `igniter` - The Igniter struct 131 | - `callback` - A function that receives the igniter and returns a modified igniter 132 | 133 | The patch function will: 134 | - Compare the before and after state of files 135 | - Generate diffs for modified files 136 | - Show creation of new files with full content 137 | - Automatically format the output with appropriate syntax highlighting 138 | 139 | **Example:** 140 | 141 | ```elixir 142 | igniter 143 | |> Igniter.Scribe.patch(fn igniter -> 144 | igniter 145 | |> Igniter.Project.Config.configure("config.exs", :your_library, [:option], true) 146 | |> Igniter.Project.Module.create_module(YourApp.SomeModule, """ 147 | defmodule YourApp.SomeModule do 148 | # Module content here 149 | end 150 | """) 151 | end) 152 | ``` 153 | 154 | ## Best Practices 155 | 156 | ### 1. Use Descriptive Section Names and Explanations 157 | 158 | Choose clear, descriptive names for your sections and provide helpful explanations: 159 | 160 | ```elixir 161 | @setup_formatter """ 162 | Configure the DSL auto-formatter. This tells the formatter to remove excess parentheses 163 | and how to sort sections in your modules for consistency. 164 | """ 165 | 166 | igniter 167 | |> Igniter.Scribe.section("Setup The Formatter", @setup_formatter, fn igniter -> 168 | # Implementation 169 | end) 170 | ``` 171 | 172 | ### 2. Group Related Changes 173 | 174 | Group logically related changes together within sections: 175 | 176 | ```elixir 177 | igniter 178 | |> Igniter.Scribe.section("Configure Application", @config_explanation, fn igniter -> 179 | igniter 180 | |> Igniter.Scribe.patch(&configure_main_settings/1) 181 | |> Igniter.Scribe.patch(&configure_optional_settings/1) 182 | |> Igniter.Scribe.patch(&setup_environment_configs/1) 183 | end) 184 | ``` 185 | 186 | ### 3. Use Module Attributes for Documentation 187 | 188 | Store your documentation strings in module attributes to keep them organized and reusable: 189 | 190 | ```elixir 191 | defmodule Mix.Tasks.YourLibrary.Install do 192 | @manual_lead_in """ 193 | This guide walks you through manually installing YourLibrary. 194 | """ 195 | 196 | @dependency_setup """ 197 | Install required dependencies and configure them for your project. 198 | """ 199 | 200 | @formatter_setup """ 201 | Configure code formatting for your DSL. 202 | """ 203 | 204 | # Use these throughout your igniter/1 function 205 | end 206 | ``` 207 | 208 | ## Example: Complete Installer 209 | 210 | Take a look at Ash Framework's installer [here](https://github.com/ash-project/ash/blob/main/lib/mix/tasks/install/ash.install.ex), and see the generated markdown file [here](https://github.com/ash-project/ash/blob/main/documentation/topics/advanced/pagination.livemd). 211 | -------------------------------------------------------------------------------- /installer/lib/mix/tasks/igniter.init_library.ex: -------------------------------------------------------------------------------- 1 | # SPDX-FileCopyrightText: 2024 igniter contributors 2 | # 3 | # SPDX-License-Identifier: MIT 4 | 5 | defmodule Mix.Tasks.Igniter.InitLibrary do 6 | @moduledoc """ 7 | Set up a library to use Igniter. Adds the optional dependency 8 | and an install task. 9 | 10 | ## Args 11 | 12 | mix igniter.init_library my_lib 13 | 14 | * `--dry-run` - Run the task without making any changes. 15 | * `--yes` - Automatically answer yes to any prompts. 16 | * `--yes-to-deps` - Automatically answer yes to any prompts about installing new deps. 17 | * `--verbose` - display additional output from various operations 18 | """ 19 | use Mix.Task 20 | 21 | @apps [:logger, :public_key, :ssl, :inets, :eex] 22 | 23 | @impl true 24 | @shortdoc "Set up a library to use Igniter. Adds the optional dependency and an install task." 25 | def run(argv) do 26 | app_name = 27 | case argv do 28 | [] -> 29 | Mix.shell().error(""" 30 | Missing argument: app name 31 | 32 | Try running `mix igniter.init_library my_lib`. 33 | """) 34 | 35 | exit({:shutdown, 1}) 36 | 37 | [app_name] -> 38 | app_name 39 | end 40 | 41 | archives_path = Mix.path_for(:archives) 42 | 43 | archive_apps = 44 | archives_path 45 | |> File.ls() 46 | |> case do 47 | {:ok, entries} -> 48 | entries 49 | 50 | _ -> 51 | [] 52 | end 53 | |> Enum.map(&Mix.Local.archive_ebin/1) 54 | |> Enum.map(&Path.join([archives_path, &1, "*.app"])) 55 | |> Enum.flat_map(&Path.wildcard/1) 56 | |> Enum.map(&Path.basename(&1, ".app")) 57 | |> Enum.map(&String.to_atom/1) 58 | 59 | for app <- @apps ++ archive_apps do 60 | try do 61 | Mix.ensure_application!(app) 62 | rescue 63 | _ -> 64 | :ok 65 | end 66 | end 67 | 68 | message = 69 | cond do 70 | "--igniter-repeat" in argv -> 71 | "setting up igniter" 72 | 73 | "--from-igniter-new" in argv -> 74 | "installing igniter" 75 | 76 | true -> 77 | "checking for igniter in project" 78 | end 79 | 80 | if !Process.get(:updated_igniter?) do 81 | Igniter.Installer.Loading.with_spinner( 82 | "Updating project's igniter dependency", 83 | fn -> 84 | System.cmd("mix", ["deps.update", "igniter"], stderr_to_stdout: true) 85 | end, 86 | verbose?: "--verbose" in argv 87 | ) 88 | 89 | Process.put(:updated_igniter?, true) 90 | end 91 | 92 | Igniter.Installer.Loading.with_spinner( 93 | message, 94 | fn -> 95 | if Code.ensure_loaded?(Igniter.Util.Install) do 96 | Mix.Task.run("deps.loadpaths", ["--no-deps-check"]) 97 | end 98 | end, 99 | verbose?: "--verbose" in argv 100 | ) 101 | 102 | argv = Enum.reject(argv, &(&1 in ["--igniter-repeat", "--from-igniter-new"])) 103 | 104 | if File.exists?("mix.exs") do 105 | contents = 106 | "mix.exs" 107 | |> File.read!() 108 | 109 | new_contents = 110 | contents 111 | |> add_igniter_dep() 112 | 113 | new_contents = 114 | if contents == new_contents do 115 | contents 116 | else 117 | new_contents 118 | |> Code.format_string!() 119 | end 120 | 121 | if new_contents == contents && !String.contains?(contents, "{:igniter,") do 122 | Mix.shell().error(""" 123 | Failed to add igniter to mix.exs. Please add it manually and try again 124 | 125 | For more information, see: https://hexdocs.pm/igniter/readme.html#installation 126 | """) 127 | 128 | exit({:shutdown, 1}) 129 | else 130 | File.write!("mix.exs", new_contents) 131 | 132 | Mix.Project.clear_deps_cache() 133 | Mix.Project.pop() 134 | Mix.Dep.clear_cached() 135 | 136 | old_undefined = Code.get_compiler_option(:no_warn_undefined) 137 | old_relative_paths = Code.get_compiler_option(:relative_paths) 138 | old_ignore_module_conflict = Code.get_compiler_option(:ignore_module_conflict) 139 | 140 | try do 141 | Code.compiler_options( 142 | relative_paths: false, 143 | no_warn_undefined: :all, 144 | ignore_module_conflict: true 145 | ) 146 | 147 | _ = Code.compile_file("mix.exs") 148 | after 149 | Code.compiler_options( 150 | relative_paths: old_relative_paths, 151 | no_warn_undefined: old_undefined, 152 | ignore_module_conflict: old_ignore_module_conflict 153 | ) 154 | end 155 | 156 | Igniter.Installer.Loading.with_spinner( 157 | "compiling igniter", 158 | fn -> 159 | case System.cmd("mix", ["deps.get"]) do 160 | {_, 0} -> 161 | :ok 162 | 163 | {output, status} -> 164 | raise(""" 165 | mix deps.get failed with exit code #{status}. 166 | 167 | Output: 168 | 169 | #{output} 170 | """) 171 | end 172 | 173 | Mix.Task.reenable("deps.compile") 174 | Mix.Task.reenable("deps.loadpaths") 175 | Mix.Task.run("deps.compile", []) 176 | Mix.Task.run("deps.loadpaths", ["--no-deps-check"]) 177 | Mix.Task.reenable("deps.compile") 178 | Mix.Task.reenable("deps.loadpaths") 179 | end, 180 | verbose?: "--verbose" in argv 181 | ) 182 | 183 | Mix.Task.run("igniter.gen.task", ["#{app_name}.install"]) 184 | end 185 | else 186 | Mix.shell().error(""" 187 | Not in a mix project. No `mix.exs` file was found. 188 | 189 | Did you mean `mix igniter.new`? 190 | """) 191 | 192 | exit({:shutdown, 1}) 193 | end 194 | end 195 | 196 | defp add_igniter_dep(contents) do 197 | version_requirement = "\"~> 0.6\"" 198 | 199 | if String.contains?(contents, "{:igniter") do 200 | Mix.shell().info("Igniter is already in the project.") 201 | contents 202 | else 203 | add_igniter_dep(contents, version_requirement) 204 | end 205 | end 206 | 207 | defp add_igniter_dep(contents, version_requirement) do 208 | if String.contains?(contents, "defp deps do\n []") do 209 | String.replace( 210 | contents, 211 | "defp deps do\n []", 212 | "defp deps do\n [{:igniter, #{version_requirement}, optional: true, runtime: false]" 213 | ) 214 | else 215 | String.replace( 216 | contents, 217 | "defp deps do\n [\n", 218 | "defp deps do\n [\n {:igniter, #{version_requirement}, optional: true, runtime: false},\n" 219 | ) 220 | end 221 | end 222 | end 223 | --------------------------------------------------------------------------------