Igniter early preview (Codemods and code generators)

Interesting discussion in the may office hours!

Questions about igniter:

  • it sounds like igniter works with a tree of code-elements that perform generation. what do you call the bit of code that implements an ‘generator operation’? an agent? a performer? an action? (ansible calls them ‘modules’)
  • is the sequencing and structure of the “igniter tree” defined within the ‘modules’ (functional composition), or by a playbook type of recipe comparable to how ansible does it?
  • how is idempotency handled?
  • could igniter emit data that describes the application structure (someone said something about a diff…)
  • can someone post the URL of the igniter repo, to let outsiders study the pre-release code?

If it’s premature for questions like these, no problemo I’m happy to wait. Just thought I’d post these questions while they are fresh in my mind. :wink:

3 Likes

:wave: great questions!

So there are two levels of granularity that are relevant here. There is a “task”, which is an abstraction over a mix task with one minor change, which is that you only define an igniter/2 function, which takes an igniter (a small wrapper around a Rewrite) and returns an igniter. This enables chaining. For example, here is a very early version of mix igniter.install.ash.resource

  defmodule Mix.Tasks.Igniter.Gen.Ash.Resource do
    use Igniter.Mix.Task

    @impl Igniter.Mix.Task
    def igniter(igniter, argv) do
      with {:ok, domain_underscore} <-
             Igniter.Args.validate_nth_present_and_underscored(
               igniter,
               argv,
               0,
               :domain,
               "Required first argument (snake_case domain) is missing"
             ),
           {:ok, resource_underscore} <-
              Igniter.Args.validate_nth_present_and_underscored(
                igniter,
                argv,
                1,
               :resource,
               "Required second argument (snake_case domain) is missing"
             ) do
        app_name = Igniter.Tasks.app_name()
        domain_module_name = Igniter.Module.module_name(Macro.camelize(domain_underscore))

        resource_module_name =
          Module.concat([domain_module_name, Macro.camelize(resource_underscore)])

        igniter
        |> Igniter.compose_task("igniter.gen.ash.resource_reference", argv)
        |> Igniter.create_new_elixir_file(
          "lib/#{app_name}/#{domain_underscore}/#{resource_underscore}.ex",
          """
          defmodule #{inspect(resource_module_name)} do
            use Ash.Resource,
              domain: #{inspect(domain_module_name)}

            actions do
              defaults [:read, :destroy, create: [], update: []]
            end

            attributes do
              uuid_primary_key :id
            end
          end
          """
        )
      else
        {:error, igniter} ->
          igniter
      end
    end
  end

That level of granularity would be called a task, and tasks can be composed with Igniter.compose_task/2. This means any task can be called via mix or composed, which is a very useful property.

At a lower level of granularity, you have individual functions that will modify a rewrite to make some code modification. We don’t currently have a name for these aside from “functions”, but you could perhaps call them “codemods”. Here is an example snippet from mix igniter.install.ash

      igniter
      |> Igniter.Deps.add_dependency(:picosat_elixir, "~> 0.2")
      |> Igniter.compose_task("igniter.install.spark", argv)
      |> Igniter.Formatter.import_dep(:ash)
      |> Igniter.Config.configure("config.exs", :spark, [:formatter, :"Ash.Resource"], [], fn x ->
        x
      end)
      |> Igniter.Config.configure("config.exs", :spark, [:formatter, :"Ash.Domain"], [], fn x ->
        x
      end)

add_dependency, import_dep and configure are examples of the building blocks Igniter will provide.

As you can see above, it is functional composition. A playbook/DSL version may come some day, but is not necessary to start.

When you run an igniter task, it displays a diff of all changes. Here is example output from running mix igniter.install ash in a new package (ignore the bit about the local dependency. You have to use a local dependency to test/work on your igniter :slight_smile:

Not looking up dependency, because a local dependency is detected
Igniter: Installing ash...

Igniter: Proposed changes:

.formatter.exs

  1 + |# Used by "mix format"
  2 + |[
  3 + |  import_deps: [:ash],
  4 + |  plugins: [Spark.Formatter],
  5 + |  inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
  6 + |]
1 7   |
2   - |


config/config.exs

  1 + |import Config
  2 + |config :spark, formatter: ["Ash.Domain": [], "Ash.Resource": [], remove_parens?: true]
  3 + |config :igniter_example, :ash_domains, [IgniterExample.ExampleDomain]
1 4   |
2   - |


lib/igniter_example/example_domain.ex

  1 + |defmodule IgniterExample.ExampleDomain do
  2 + |  use Ash.Domain
1 3   |
  4 + |  resources do
  5 + |    resource(IgniterExample.ExampleDomain.ExampleResource)
  6 + |  end
  7 + |end


lib/igniter_example/example_domain/example_resource.ex

    1 + |defmodule IgniterExample.ExampleDomain.ExampleResource do
    2 + |  use Ash.Resource,
    3 + |    domain: IgniterExample.ExampleDomain
 1  4   |
    5 + |  actions do
    6 + |    defaults([:read, :destroy, create: [], update: []])
    7 + |  end
 2  8   |
    9 + |  attributes do
   10 + |    uuid_primary_key(:id)
   11 + |  end
   12 + |end
   13 + |


Proceed with changes? [Yn]

Since those are all new files, I need to figure out a way to hide the left side of those line numbers representing the original files, just haven’t done that yet :slight_smile:

The codebase is not quite ready, but I will share it before too long.

6 Likes

In the spirit of building in the open, the source code has been made public: GitHub - ash-project/igniter There isn’t much to it yet, but more will come, and a 0.1 release should happen in the next few weeks ideally.

9 Likes

Waiting! :slight_smile: