Skip to content

improvement: within tests and docs #267

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions documentation/writing-installers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Writing Installers
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets call this guide "writing installers & generators".

The logic is essentially the same, the only thing is that if your task is called <your_library>.install then it will be invoked when mix igniter.install is called.


Generate the install task:
```
mix igniter.gen.task my_dep.install
```
All of the work will be done in the igniter call:

```elixir
@impl Igniter.Mix.Task
def igniter(igniter) do
# Do your work here and return an updated igniter
igniter
|> Igniter.add_warning("mix routex.install is not yet implemented")
end
```

## Fetching Dependency

```elixir
if Igniter.Project.Deps.has_dep?(igniter, :my_dep) do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So you don't have to do this typically. I'd say let's remove this example. If your installer should install additional packages or add additional dependencies, that should be configured in the installer. i.e installs: [{:other_dep, ...}], adds_deps: [{:other_dep2, ...}].

You don't need to add the specific dep that you are installing, as igniter handles that.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤔
When I comment out fetch_dep which does above and do in info:

        adds_deps: [{:routex, "~> 1.1"}],
        installs: [{:routex, "~> 1.1"}],

No changes detected on assert_has_patch("mix.exs", ...)

Refering to this

Am I missing something? :thinkies:

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In tests we use compose_task which does not call the full installer. but when using igniter.install it will.

igniter
else
igniter
|> Igniter.Project.Deps.add_dep({:my_dep, "~> 1.0"})
|> Igniter.apply_and_fetch_dependencies(error_on_abort?: true, yes_to_deps: true)
end
```

Check does the dependency already exist and if not add it and apply it.


## Creating a module

```elixir
Igniter.Project.Module.create_module(igniter, module, """
use MyDep.Module
""")
```

Create a new module that uses some module from your application.

## Modifying a module

For example:

```elixir
defmodule SomeModule do
alias Some.Example

def some_function(a, b) do
Example.function(a, b)
end
end
```

Find that module and update it:

```elixir
Igniter.Project.Module.find_and_update_module!(igniter, SomeModule, fn zipper ->
{:ok, igniter}
end)
```

In the function block use [`within/2`](https://hexdocs.pm/igniter/Igniter.Code.Common.html#within/2) to do multiple modifications. Find the part you want to change and modify it.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this tip, but let's maybe put it in its own section called like "making multiple modifications to a zipper"? Something good to describe also is the general concept of:

Igniter provides a high level API for working with a data structure called a "zipper". This data structure is defined by sourceror (link to sourceror), and at a certain level of generator complexity, you will inevitably end up working with zippers. We recommend reading through sourcerer's documentation. While we provide many tools for working with source code (Igniter.Code.*, or working with project level concepts (Igniter.Project.*), you may find yourself needing to use functions from Sourceror.Zipper. This is normal. We provide high level wrappers around many sourceror operations that are "smart" and handle various edge cases and source code formats for you, but not every function from Sourceror.Zipper needs a corresponding function from Igniter.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So much to learn 🧠 I should put this back to draft. ✔️

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're doing great 😄 This is an important guide to get right and I'm grateful you're putting in the effort here 🙇

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I just realized we have a "Writing generators" guide: https://github.com/ash-project/igniter/blob/main/documentation/writing-generators.md

Maybe lets merge these with the name "Writing installers and generators" 😄


For example:
- replace alias with an import:
```elixir
Igniter.Code.Common.within(fn zipper ->
pred = &match?(%Zipper{node: {:alias, _, _}}, &1)
zipper = Common.remove(zipper, pred)
line = "import Some.Example, only: [:function]"
{:ok, Common.add_code(zipper, line, placement: :before)}
end)
```

- replace function block:
```elixir
Igniter.Code.Common.within(fn zipper ->
{:ok, zipper} = move_to_function(zipper, :some_function)
{:ok, zipper} = Common.move_to_do_block(zipper)
line = "my_private_function!(function(a, b))"
{:ok, Igniter.Code.Common.replace_code(zipper, line)}
end)
```

- add a block:
```elixir
Igniter.Code.Common.within(fn zipper ->
block = """
defp private_function!({:ok, result}), do: result
defp private_function!(_), do: raise "Something went wrong!"
"""

{:ok, Common.add_code(zipper, block, placement: :after)}
end)
```

You can chain within blocks using `with`.
1 change: 1 addition & 0 deletions mix.exs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ defmodule Igniter.MixProject do
extras: [
{"README.md", title: "Home"},
"documentation/writing-generators.md",
"documentation/writing-installers.md",
"documentation/configuring-igniter.md",
"documentation/upgrades.md",
"CHANGELOG.md"
Expand Down
82 changes: 82 additions & 0 deletions test/igniter/code/within_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
defmodule Igniter.Code.WithinTest do
alias Igniter.Code.Common
alias Sourceror.Zipper
use ExUnit.Case
import Igniter.Test

describe "with within" do
test "performs multiple changes to some module" do
test_project()
|> Igniter.create_new_file("lib/some_module.ex", """
defmodule SomeModule do
alias Some.Example

def some_function(a, b) do
Example.function(a, b)
end
end
""")
|> apply_igniter!()
|> Igniter.Project.Module.find_and_update_module!(SomeModule, fn zipper ->
zipper
|> within!(fn zipper ->
pred = &match?(%Zipper{node: {:alias, _, _}}, &1)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We never really want to match on the zipper node if we can avoid it. For this example I'd pick something exactly matching for simplicity, like Igniter.Code.Common.nodes_equal?(zipper, Some.Module)

Copy link
Contributor

@zachdaniel zachdaniel Apr 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, ignore that, use Igniter.Code.Function.function_call?(zipper, :alias, 1)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even better:

Igniter.Code.Function.function_call?(zipper, :alias, 1, &Igniter.Code.Function.argument_equals?(&1, 0, The.Module.To.Replace)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Your example using remove will actually remove all aliases from the module, so that's why you want to be as specific as possible :).

The way I would typically recommend doing it is instead of using remove and add, you would move to the location of the node you want to modify and then replace that node.

case move_to_my_alias(zipper) do
  {:ok, zipper} -> 
    {:ok, Igniter.Code.Common.replace_code(zipper, "import Some.Example, only: [:function]")}
  :error ->
    {:ok, zipper}
end

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I knew my way wasn't the right way 😂 But this way I'll learn the right way. 💯

zipper = Common.remove(zipper, pred)
line = "import Some.Example, only: [:function]"
{:ok, Common.add_code(zipper, line, placement: :before)}
end)
|> within!(fn zipper ->
{:ok, zipper} = move_to_function(zipper, :some_function)
{:ok, zipper} = Common.move_to_do_block(zipper)
line = "my_private_function!(function(a, b))"
{:ok, Igniter.Code.Common.replace_code(zipper, line)}
end)
|> within!(fn zipper ->
block = """
defp private_function!({:ok, result}), do: result
defp private_function!(_), do: raise "Something went wrong!"
"""

{:ok, Common.add_code(zipper, block, placement: :after)}
end)
|> then(fn zipper -> {:ok, zipper} end)
end)
|> Igniter.Refactors.Rename.rename_function(
{SomeModule, :some_function},
{SomeModule, :some_function!},
arity: 2
)
|> assert_has_patch("lib/some_module.ex", """
|defmodule SomeModule do
- | alias Some.Example
+ | import Some.Example, only: [:function]
|
- | def some_function(a, b) do
- | Example.function(a, b)
+ | def some_function!(a, b) do
+ | my_private_function!(function(a, b))
| end
+ |
+ | defp private_function!({:ok, result}),
+ | do: result
+ |
+ | defp private_function!(_),
+ | do: raise("Something went wrong!")
|end
""")
end
end

defp within!(zipper, function) do
case Common.within(zipper, function) do
{:ok, zipper} -> zipper
:error -> raise "Error calling within"
end
end

defp move_to_function(zipper, function) do
Igniter.Code.Common.move_to(zipper, fn zipper ->
Igniter.Code.Function.function_call?(zipper, function)
end)
end
end