Skip to content

Commit

Permalink
WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
zachdaniel committed Jun 5, 2024
1 parent 57ee3f9 commit 4148525
Show file tree
Hide file tree
Showing 6 changed files with 185 additions and 25 deletions.
53 changes: 53 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,56 @@
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Hex version badge](https://img.shields.io/hexpm/v/igniter.svg)](https://hex.pm/packages/igniterh)
[![Hexdocs badge](https://img.shields.io/badge/docs-hexdocs-purple)](https://hexdocs.pm/igniter)

# Igniter

Igniter is a code generation and project patching framework.

## Installation

Igniter can be added to an existing elixir project by adding it to your dependencies:

```elixir
{:igniter, "~> 0.1", only: [:dev]}
```

You can also generate new projects with igniter preinstalled, and run installers in the same command.

The archive is not published yet, so these instructions are ommitted, but once it is, you will be able to install the archive, and say:

```
mix igniter.new app_name --install ash
```

## Patterns

Mix tasks built with igniter are both individually callable, _and_ composable. This means that tasks can call eachother, and also end users can create and customize their own generators composing existing tasks.

### Installers

Igniter will look for a task called `<your_package>.install` when the user runs `mix igniter.install <your_package>`, and will run it after installing and fetching dependencies.

### Generators/Patchers

These can be run like any other mix task, or composed together. For example, lets say that you wanted to have your own `Ash.Resource` generator, that starts with the default `mix ash.gen.resource` task, but then adds or modifies files:

```elixir
# in lib/mix/tasks/my_app.gen.resource.ex
defmodule Mix.Tasks.MyApp.Gen.Resource do
use Igniter.Mix.Task

def igniter(igniter, [resource | _] = argv) do
resource = Igniter.Module.parse(resource)
my_special_thing = Module.concat([resource, SpecialThing])
location = Igniter.Module.proper_location(my_special_thing)

igniter
|> Igniter.compose_task("ash.gen.resource", argv)
|> Igniter.create_new_elixir_file(location, """
defmodule #{inspect(my_special_thing)} do
# this is the special thing for #{inspect()}
end
""")
end
end
```
97 changes: 82 additions & 15 deletions lib/common.ex
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,14 @@ defmodule Igniter.Common do
Igniter.Common.argument_matches_predicate?(
unquote(zipper),
unquote(index),
&match?(unquote(pattern), &1)
fn zipper ->
code_at_node =
zipper
|> Zipper.subtree()
|> Zipper.root()

match?(unquote(pattern), code_at_node)
end
)
end
end
Expand Down Expand Up @@ -211,6 +218,29 @@ defmodule Igniter.Common do
{:ok, updater.(zipper)}
end
end
else
:error
end
end

def remove_keyword_key(zipper, key) do
original_zipper = zipper

if node_matches_pattern?(zipper, value when is_list(value)) do
case move_to_list_item(zipper, fn item ->
if tuple?(item) do
first_elem = tuple_elem(item, 0)
first_elem && node_matches_pattern?(first_elem, ^key)
end
end) do
:error ->
zipper

{:ok, zipper} ->
zipper |> Zipper.remove() |> Map.put(:path, original_zipper.path)
end
else
zipper
end
end

Expand Down Expand Up @@ -341,15 +371,32 @@ defmodule Igniter.Common do
end
end

def move_to_function_call_in_current_scope(zipper, name, arity, predicate \\ fn _ -> true end) do
def move_to_function_call_in_current_scope(zipper, name, arity, predicate \\ fn _ -> true end)

def move_to_function_call_in_current_scope(zipper, name, [arity | arities], predicate)
when is_list(arities) do
case move_to_function_call_in_current_scope(zipper, name, arity, predicate) do
:error ->
move_to_function_call_in_current_scope(zipper, name, arities, predicate)

{:ok, zipper} ->
{:ok, zipper}
end
end

def move_to_function_call_in_current_scope(_, _, [], _) do
:error
end

def move_to_function_call_in_current_scope(%Zipper{} = zipper, name, arity, predicate) do
zipper
|> maybe_move_to_block()
|> move_right(fn zipper ->
function_call?(zipper, name, arity) && predicate.(zipper)
end)
end

def function_call?(zipper, name, arity) do
def function_call?(%Zipper{} = zipper, name, arity) do
zipper
|> maybe_move_to_block()
|> Zipper.subtree()
Expand Down Expand Up @@ -494,6 +541,11 @@ defmodule Igniter.Common do
end
end

def append_argument(zipper, value) do
zipper
|> Zipper.append_child(value)
end

def argument_matches_predicate?(zipper, index, func) do
if pipeline?(zipper) do
if index == 0 do
Expand Down Expand Up @@ -528,8 +580,6 @@ defmodule Igniter.Common do
{:ok, zipper} ->
zipper
|> maybe_move_to_block()
|> Zipper.subtree()
|> Zipper.root()
|> func.()
end
end
Expand All @@ -552,8 +602,6 @@ defmodule Igniter.Common do
{:ok, zipper} ->
zipper
|> maybe_move_to_block()
|> Zipper.subtree()
|> Zipper.root()
|> func.()
end
end
Expand Down Expand Up @@ -612,8 +660,14 @@ defmodule Igniter.Common do
end
end

def move_to_use(zipper, module) do
move_to_function_call_in_current_scope(zipper, :use, [1, 2], fn call ->
argument_matches_predicate?(call, 0, &equal_modules?(&1, module))
end)
end

# aliases will confuse this, but that is a later problem :)
def equal_modules?(zipper, module) do
def equal_modules?(%Zipper{} = zipper, module) do
root =
zipper
|> Zipper.subtree()
Expand Down Expand Up @@ -661,11 +715,18 @@ defmodule Igniter.Common do
:error

{:ok, zipper} ->
{:ok,
zipper
|> Zipper.down()
|> Zipper.rightmost()
|> maybe_move_to_block()}
zipper
|> Zipper.down()
|> case do
nil ->
:error

zipper ->
{:ok,
zipper
|> Zipper.rightmost()
|> maybe_move_to_block()}
end
end
end

Expand All @@ -679,7 +740,13 @@ defmodule Igniter.Common do
{:__block__, _, _} ->
zipper
|> Zipper.down()
|> maybe_move_to_block()
|> case do
nil ->
zipper

zipper ->
maybe_move_to_block(zipper)
end

_ ->
zipper
Expand Down Expand Up @@ -851,7 +918,7 @@ defmodule Igniter.Common do
end
end

defp move_right(zipper, pred) do
defp move_right(%Zipper{} = zipper, pred) do
zipper_in_block = maybe_move_to_block(zipper)

if pred.(zipper_in_block) do
Expand Down
38 changes: 38 additions & 0 deletions lib/igniter.ex
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ defmodule Igniter do
apply_func_with_zipper(source, func)
end)
}
|> format(path)
else
if File.exists?(path) do
source = Rewrite.Source.Ex.read!(path)
Expand Down Expand Up @@ -169,6 +170,34 @@ defmodule Igniter do
end
end

def create_or_update_elixir_file(igniter, path, contents, func) do
if Rewrite.has_source?(igniter.rewrite, path) do
igniter
|> update_elixir_file(path, func)
else
{created?, source} =
try do
{false, Rewrite.Source.Ex.read!(path)}
rescue
_ ->
{true,
""
|> Rewrite.Source.Ex.from_string(path)
|> Rewrite.Source.update(:file_creator, :content, contents)}
end

%{igniter | rewrite: Rewrite.put!(igniter.rewrite, source)}
|> format(path)
|> then(fn igniter ->
if created? do
igniter
else
update_elixir_file(igniter, path, func)
end
end)
end
end

def create_new_elixir_file(igniter, path, contents \\ "") do
source =
try do
Expand Down Expand Up @@ -456,6 +485,7 @@ defmodule Igniter do
defp find_formatter_exs_file_options(path, formatter_exs_files) do
case Map.fetch(formatter_exs_files, path) do
{:ok, source} ->
Rewrite.Source.get(source, :content) |> IO.puts()
{opts, _} = Rewrite.Source.get(source, :quoted) |> Code.eval_quoted()

{:ok, eval_deps(opts)}
Expand Down Expand Up @@ -539,6 +569,14 @@ defmodule Igniter do
zipper = Sourceror.Zipper.zip(quoted)

case func.(zipper) do
{:ok, %Sourceror.Zipper{} = zipper} ->
Rewrite.Source.update(
source,
:configure,
:quoted,
Sourceror.Zipper.root(zipper)
)

%Sourceror.Zipper{} = zipper ->
Rewrite.Source.update(
source,
Expand Down
12 changes: 7 additions & 5 deletions lib/install.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,19 @@ defmodule Igniter.Install do
]
]

# only supports hex installation at the moment
def install(install, argv) do
install_list = String.split(install, ",")
install_list =
if is_binary(install) do
String.split(install, ",")
else
Enum.map(List.wrap(install), &to_string/1)
end

Application.ensure_all_started(:req)

{options, _, _unprocessed_argv} =
{options, _errors, _unprocessed_argv} =
OptionParser.parse(argv, @option_schema)

argv = OptionParser.to_argv(options)

igniter = Igniter.new()

{igniter, install_list} =
Expand Down
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule Igniter.MixProject do
[
app: :igniter,
version: @version,
elixir: "~> 1.16",
elixir: "~> 1.15",
start_permanent: Mix.env() == :prod,
aliases: aliases(),
docs: docs(),
Expand Down
8 changes: 4 additions & 4 deletions test/config_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ defmodule Igniter.ConfigTest do
config_file = Rewrite.source!(rewrite, "config/fake.exs")

assert Source.get(config_file, :content) == """
config :fake, foo: [bar: "baz"], buz: [:blat]
config :fake, buz: [:blat], foo: [bar: "baz"]
"""
end

Expand All @@ -54,7 +54,7 @@ defmodule Igniter.ConfigTest do

assert Source.get(config_file, :content) == """
import Config
config :spark, formatter: ["Ash.Domain": [], "Ash.Resource": []]
config :spark, formatter: ["Ash.Resource": [], "Ash.Domain": []]
"""
end

Expand All @@ -69,7 +69,7 @@ defmodule Igniter.ConfigTest do
config_file = Rewrite.source!(rewrite, "config/fake.exs")

assert Source.get(config_file, :content) == """
config :fake, foo: "baz", buz: [:blat]
config :fake, buz: [:blat], foo: "baz"
"""
end

Expand Down Expand Up @@ -143,7 +143,7 @@ defmodule Igniter.ConfigTest do
config_file = Rewrite.source!(rewrite, "config/fake.exs")

assert Source.get(config_file, :content) == """
config :fake, foo: %{"b" => ["c", "d"], "a" => ["a", "b"]}
config :fake, foo: %{"a" => ["a", "b"], "b" => ["c", "d"]}
"""
end
end
Expand Down

0 comments on commit 4148525

Please sign in to comment.